From 0c4989602b985a961b2735e67c411be6982ac0a3 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Tue, 12 May 2026 23:58:23 -0400 Subject: [PATCH 01/36] feat(gv): GravityView Inspector REST tool family (gv_*) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 30 gv_* tools alongside the existing gf_* surface, covering full View authoring against the GravityView Inspector REST API. Authoring flow: gv_create_view (defaults to gravityview-layout-builder, accepts per-zone template_ids for Multiple/Single Entry divergence) → gv_create_grid_row (surface=fields|widgets) → gv_apply_view_config for bulk one-shot writes OR surgical tools (gv_add_view_field / gv_patch_view_field / gv_move_view_field) for incremental edits. For search bars: gv_add_search_field / gv_patch_search_field / gv_remove_search_field write the modern search_fields_section tree; existing legacy widgets auto-migrate on first save through this API. Discovery: gv_list_templates / gv_list_widgets / gv_list_grid_row_types / gv_list_widget_zones / gv_list_search_zones / gv_list_view_forms / gv_list_available_fields / gv_get_view_areas / gv_get_view_field_schemas. gv_get_field_type_schema dispatches by kind (field | widget | search_field) so one tool covers every type catalogue. Move semantics borrowed from block-mcp: to.before_slot / to.after_slot for ref-relative placement, position="start"|"end"|integer for symbolic. Concurrency via ifMatch="auto" pulls from a per-view version cache. Compact responses by default (compact=false for raw). Validator pre-flights structural mistakes (mode enum, required fields, LB area row_uid existence) and optionally schema-aware setting validation when validateAgainstSchemas: true is passed. Files: - src/gravityview-client.js: HTTP client (Basic auth via WP App Password, ETag/If-Match cache, all endpoint methods). - src/view-operations/{index,view-validator}.js: tool definitions + handler routing + client-side validation. - src/index.js: server bootstrap, gv_* dispatch via wrapViewHandler that surfaces the inspector REST envelope (gv_rest_* error codes) rather than generic axios errors. - src/tests/views.test.js: 25 tests covering construction, auth fallback, discovery, create + version cache, apply with If-Match, Layout Builder area-key encoding, allow-delete guard, render GET-vs-POST, and validator (structural + schema-aware). - package.json: test:views script wired into test:all. --- package.json | 3 +- src/gravityview-client.js | 547 ++++++++++++++++++++++++++ src/index.js | 93 ++++- src/tests/views.test.js | 365 +++++++++++++++++ src/view-operations/index.js | 507 ++++++++++++++++++++++++ src/view-operations/view-validator.js | 253 ++++++++++++ 6 files changed, 1761 insertions(+), 7 deletions(-) create mode 100644 src/gravityview-client.js create mode 100644 src/tests/views.test.js create mode 100644 src/view-operations/index.js create mode 100644 src/view-operations/view-validator.js diff --git a/package.json b/package.json index 187e841..1fb69c5 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "test:field-validation": "node src/tests/field-validation.test.js", "test:tools": "node src/tests/server-tools.test.js", "test:compact": "node src/tests/compact.test.js", - "test:all": "npm run test:unit && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm test", + "test:views": "node src/tests/views.test.js", + "test:all": "npm run test:unit && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm run test:views && npm test", "test:coverage": "echo 'Running all tests with coverage analysis' && npm run test:all" }, "keywords": [ diff --git a/src/gravityview-client.js b/src/gravityview-client.js new file mode 100644 index 0000000..44f8a00 --- /dev/null +++ b/src/gravityview-client.js @@ -0,0 +1,547 @@ +/** + * GravityView Inspector REST API Client. + * + * Wraps the `/wp-json/gravityview/v1/...` endpoints exposed by the + * GravityView plugin's Inspector route family (see + * `src/REST/InspectorRoute.php` in the GravityView codebase). + * + * Authentication: WordPress Application Password via HTTP Basic Auth. + * The same WP install hosts both the GF REST and the GravityView REST + * surfaces, so when WP_USERNAME / WP_APP_PASSWORD aren't set we fall + * back to GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET + * (which in practice are usually a WP user + app password too — most + * local-dev setups reuse them rather than minting two separate + * credentials). + * + * Concurrency: every config write supports `If-Match: ""` + * for optimistic-concurrency. Reads return the version in the body + * AND in an ETag response header. The client keeps a per-view-id + * version cache so callers can do `client.applyConfig(id, payload, + * { ifMatch: 'auto' })` without juggling ETags by hand. + */ + +import axios from 'axios'; +import https from 'https'; +import logger from './utils/logger.js'; + +export class GravityViewClient { + constructor(config) { + this.config = config || {}; + + const baseUrl = this.resolveBaseUrl(); + if (!baseUrl) { + throw new Error('GravityView client requires GRAVITYVIEW_BASE_URL or GRAVITY_FORMS_BASE_URL.'); + } + if (!baseUrl.startsWith('https://') && !baseUrl.startsWith('http://')) { + throw new Error('GravityView base URL must start with http:// or https://'); + } + + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.restNamespace = '/wp-json/gravityview/v1'; + + const username = this.config.GRAVITYVIEW_WP_USERNAME || this.config.WP_USERNAME || this.config.GRAVITY_FORMS_CONSUMER_KEY; + const password = this.config.GRAVITYVIEW_WP_APP_PASSWORD || this.config.WP_APP_PASSWORD || this.config.GRAVITY_FORMS_CONSUMER_SECRET; + if (!username || !password) { + throw new Error('GravityView client requires WordPress credentials. Set GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD (or reuse GRAVITY_FORMS_CONSUMER_KEY/SECRET).'); + } + this.basicAuth = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); + + const allowSelfSigned = (this.config.GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS || this.config.MCP_ALLOW_SELF_SIGNED_CERTS) === 'true'; + + this.httpClient = axios.create({ + baseURL: `${this.baseUrl}${this.restNamespace}`, + timeout: parseInt(this.config.GRAVITYVIEW_TIMEOUT || this.config.GRAVITY_FORMS_TIMEOUT, 10) || 30000, + headers: { + 'User-Agent': 'GravityKit-MCP/2.1.1 (gravityview)', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': this.basicAuth, + }, + httpsAgent: new https.Agent({ rejectUnauthorized: !allowSelfSigned }), + }); + + // version cache keyed by view id — populated by every read so + // callers can opt into automatic If-Match without a manual GET. + this.versionCache = new Map(); + + this.allowDelete = this.config.GRAVITYVIEW_ALLOW_DELETE === 'true' || this.config.GRAVITY_FORMS_ALLOW_DELETE === 'true'; + } + + resolveBaseUrl() { + return this.config.GRAVITYVIEW_BASE_URL || this.config.GRAVITY_FORMS_BASE_URL || ''; + } + + /** + * Ping the templates endpoint to verify credentials + connectivity. + * Cheap (no view id required, server returns the registered template + * registry which is small). + */ + async testConnection() { + try { + const response = await this.httpClient.get('/templates'); + return { + success: true, + templateCount: Array.isArray(response.data?.templates) ? response.data.templates.length : 0, + baseUrl: `${this.baseUrl}${this.restNamespace}`, + }; + } catch (error) { + return { + success: false, + status: error.response?.status, + error: error.response?.data?.message || error.message, + }; + } + } + + // =================================================================== + // Discovery (no view id needed) + // =================================================================== + + async listTemplates() { + const { data } = await this.httpClient.get('/templates'); + return data; + } + + async listWidgets() { + const { data } = await this.httpClient.get('/widgets'); + return data; + } + + async listGridRowTypes() { + const { data } = await this.httpClient.get('/grid/row-types'); + return data; + } + + async listWidgetZones() { + const { data } = await this.httpClient.get('/widget-zones'); + return data; + } + + async listSearchZones() { + const { data } = await this.httpClient.get('/search-zones'); + return data; + } + + async listForms() { + const { data } = await this.httpClient.get('/forms'); + return data; + } + + async getFieldTypeSchema({ field_type, template_id, context, input_type, form_id } = {}) { + if (!field_type) throw new Error('field_type is required.'); + const params = stripUndefined({ template_id, context, input_type, form_id }); + const { data } = await this.httpClient.get(`/field-types/${encodeURIComponent(field_type)}/schema`, { params }); + return data; + } + + // =================================================================== + // Reads (per view) + // =================================================================== + + async getViewConfig({ id } = {}) { + requireViewId(id); + const response = await this.httpClient.get(`/views/${id}/config`); + this.cacheVersion(id, response); + return response.data; + } + + async getViewAreas({ id } = {}) { + requireViewId(id); + const { data } = await this.httpClient.get(`/views/${id}/areas`); + return data; + } + + async listAvailableFields({ id } = {}) { + requireViewId(id); + const { data } = await this.httpClient.get(`/views/${id}/available-fields`); + return data; + } + + async getViewFieldSchemas({ id } = {}) { + requireViewId(id); + const { data } = await this.httpClient.get(`/views/${id}/field-settings-schema`); + return data; + } + + async getFieldSettingsSchema({ id, area, slot } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + const { data } = await this.httpClient.get( + `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}/settings-schema` + ); + return data; + } + + async renderViewField({ id, area, slot, settings } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + // Server accepts both GET and POST — POST when staged settings + // overrides need to ride along (without persisting). + if (settings && typeof settings === 'object') { + const { data } = await this.httpClient.post( + `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}/render`, + { settings } + ); + return data; + } + const { data } = await this.httpClient.get( + `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}/render` + ); + return data; + } + + // =================================================================== + // Create + // =================================================================== + + async createView({ + title, form_id, template_id, template_ids, status, + template_settings, search_criteria, fields, widgets, mode, + } = {}) { + if (!title || typeof title !== 'string') throw new Error('title is required.'); + if (!Number.isInteger(form_id) || form_id <= 0) throw new Error('form_id (positive integer) is required.'); + const payload = stripUndefined({ + title, + form_id, + template_id, + template_ids, + status, + template_settings, + search_criteria, + fields, + widgets, + mode, + }); + const response = await this.httpClient.post('/views', payload); + this.cacheVersion(response.data?.view_id ?? response.data?.id, response); + return response.data; + } + + // =================================================================== + // Bulk apply + // =================================================================== + + async applyViewConfig({ id, template_id, template_ids, template_settings, search_criteria, fields, widgets, mode, ifMatch } = {}) { + requireViewId(id); + const payload = stripUndefined({ template_id, template_ids, template_settings, search_criteria, fields, widgets, mode }); + const response = await this.httpClient.post(`/views/${id}/config/_apply`, payload, this.ifMatchHeaders(id, ifMatch)); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Surgical writes — settings + template + // =================================================================== + + async setViewTemplate({ id, template_id, zone, policy, ifMatch } = {}) { + requireViewId(id); + if (!template_id) throw new Error('template_id is required.'); + const payload = stripUndefined({ template_id, zone, policy }); + const response = await this.httpClient.patch(`/views/${id}/template`, payload, this.ifMatchHeaders(id, ifMatch)); + this.cacheVersion(id, response); + return response.data; + } + + async patchViewSettings({ id, template_settings, ifMatch } = {}) { + requireViewId(id); + if (!template_settings || typeof template_settings !== 'object') { + throw new Error('template_settings (object) is required.'); + } + const response = await this.httpClient.patch(`/views/${id}/template-settings`, template_settings, this.ifMatchHeaders(id, ifMatch)); + this.cacheVersion(id, response); + return response.data; + } + + async patchViewSearchCriteria({ id, search_criteria, ifMatch } = {}) { + requireViewId(id); + if (!search_criteria || typeof search_criteria !== 'object') { + throw new Error('search_criteria (object) is required.'); + } + const response = await this.httpClient.patch(`/views/${id}/search-criteria`, search_criteria, this.ifMatchHeaders(id, ifMatch)); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Surgical writes — fields + // =================================================================== + + async addViewField({ id, area, field, ifMatch } = {}) { + requireViewId(id); + if (!area) throw new Error('area is required.'); + if (!field || typeof field !== 'object') throw new Error('field (object) is required.'); + if (!field.field_id) throw new Error('field.field_id is required.'); + const response = await this.httpClient.post( + `/views/${id}/fields/${encodeArea(area)}/_slots`, + field, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async patchViewField({ id, area, slot, settings, ifMatch } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + if (!settings || typeof settings !== 'object') { + throw new Error('settings (object) is required.'); + } + const response = await this.httpClient.patch( + `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}`, + settings, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async moveViewField({ id, from, to, position, ifMatch } = {}) { + requireViewId(id); + if (!from?.area || !from?.slot) throw new Error('from { area, slot } is required.'); + if (!to?.area) throw new Error('to { area } is required.'); + // `to` may carry before_slot / after_slot for ref-relative + // placement; the server resolves precedence (before > after > + // position). `position` accepts "start" | "end" | integer. + const payload = stripUndefined({ from, to, position }); + const response = await this.httpClient.post(`/views/${id}/fields/_move`, payload, this.ifMatchHeaders(id, ifMatch)); + this.cacheVersion(id, response); + return response.data; + } + + async removeViewField({ id, area, slot, ifMatch } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + if (!this.allowDelete) { + throw new Error('Deletion disabled. Set GRAVITYVIEW_ALLOW_DELETE=true to allow destructive operations.'); + } + const response = await this.httpClient.delete( + `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}`, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Grid (Layout Builder) row CRUD + // =================================================================== + + /** + * Add a Layout Builder grid row. + * + * @param {object} params + * @param {number} params.id View id. + * @param {string} [params.type='100'] Row type — see lookup_grid_row_type + * on the server. Common values: "100", "50/50", "33/66", "66/33", + * "33/33/33", "25/25/25/25", "25/25/50", "25/50/25", "50/25/25". + * @param {string[]} [params.zones] Zones to materialise the row in. + * Defaults server-side to ["directory","single","edit"]. + */ + async addGridRow({ id, surface, type, zones, ifMatch } = {}) { + requireViewId(id); + const payload = stripUndefined({ surface, type, zones }); + const response = await this.httpClient.post( + `/views/${id}/grid/_rows`, + payload, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + /** + * Re-key every field in a row from the old type to the new type. When + * the new type has fewer columns, surplus fields collapse into the + * first column. + */ + async patchGridRow({ id, surface, row_uid, type, ifMatch } = {}) { + requireViewId(id); + if (!row_uid) throw new Error('row_uid is required.'); + if (!type) throw new Error('type is required.'); + const response = await this.httpClient.patch( + `/views/${id}/grid/_rows/${encodeURIComponent(row_uid)}`, + stripUndefined({ surface, type }), + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + /** Remove a grid row and every field placed in any of its areas. */ + async deleteGridRow({ id, surface, row_uid, ifMatch } = {}) { + requireViewId(id); + if (!row_uid) throw new Error('row_uid is required.'); + if (!this.allowDelete) { + throw new Error('Deletion disabled. Set GRAVITYVIEW_ALLOW_DELETE=true to allow destructive operations.'); + } + // axios.delete requires `data` inside the config to send a body. + const config = this.ifMatchHeaders(id, ifMatch) || {}; + if (surface) config.data = { surface }; + const response = await this.httpClient.delete( + `/views/${id}/grid/_rows/${encodeURIComponent(row_uid)}`, + config + ); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Search Bar internal slot CRUD (modern shape only) + // =================================================================== + + async addSearchField({ id, widget_area, widget_slot, position, field, slot, ifMatch } = {}) { + requireViewId(id); + if (!widget_area || !widget_slot) throw new Error('widget_area and widget_slot are required.'); + if (!position) throw new Error('position is required (e.g. "search-general_top::100::ROW_UID").'); + if (!field || typeof field !== 'object' || !field.id) { + throw new Error('field must be an object with at least an `id` (e.g. "search_all", "submit", or a GF field id).'); + } + const response = await this.httpClient.post( + `/views/${id}/search-fields/_slots`, + stripUndefined({ widget_area, widget_slot, position, field, slot }), + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async patchSearchField({ id, widget_area, widget_slot, position, search_slot, settings, ifMatch } = {}) { + requireViewId(id); + if (!widget_area || !widget_slot) throw new Error('widget_area and widget_slot are required.'); + if (!position) throw new Error('position is required.'); + if (!search_slot) throw new Error('search_slot is required.'); + if (!settings || typeof settings !== 'object') throw new Error('settings must be an object.'); + const response = await this.httpClient.patch( + `/views/${id}/search-fields/${encodeURIComponent(search_slot)}`, + { widget_area, widget_slot, position, settings }, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async removeSearchField({ id, widget_area, widget_slot, position, search_slot, ifMatch } = {}) { + requireViewId(id); + if (!widget_area || !widget_slot || !position || !search_slot) { + throw new Error('widget_area, widget_slot, position, and search_slot are required.'); + } + if (!this.allowDelete) { + throw new Error('Deletion disabled. Set GRAVITYVIEW_ALLOW_DELETE=true to allow destructive operations.'); + } + const config = this.ifMatchHeaders(id, ifMatch) || {}; + config.data = { widget_area, widget_slot, position }; + const response = await this.httpClient.delete( + `/views/${id}/search-fields/${encodeURIComponent(search_slot)}`, + config + ); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Surgical writes — widgets + // =================================================================== + + async addViewWidget({ id, area, widget, ifMatch } = {}) { + requireViewId(id); + if (!area) throw new Error('area is required.'); + if (!widget || typeof widget !== 'object') throw new Error('widget (object) is required.'); + if (!widget.field_id) throw new Error('widget.field_id is required (use the widget id, e.g. "search_bar").'); + const response = await this.httpClient.post( + `/views/${id}/widgets/${encodeURIComponent(area)}/_slots`, + widget, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async patchViewWidget({ id, area, slot, settings, ifMatch } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + if (!settings || typeof settings !== 'object') { + throw new Error('settings (object) is required.'); + } + const response = await this.httpClient.patch( + `/views/${id}/widgets/${encodeURIComponent(area)}/${encodeURIComponent(slot)}`, + settings, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async removeViewWidget({ id, area, slot, ifMatch } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + if (!this.allowDelete) { + throw new Error('Deletion disabled. Set GRAVITYVIEW_ALLOW_DELETE=true to allow destructive operations.'); + } + const response = await this.httpClient.delete( + `/views/${id}/widgets/${encodeURIComponent(area)}/${encodeURIComponent(slot)}`, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Internals + // =================================================================== + + cacheVersion(viewId, response) { + if (!viewId) return; + const tag = response?.headers?.etag || response?.headers?.ETag; + let version; + if (typeof tag === 'string') { + version = tag.replace(/^"(.*)"$/, '$1'); + } else if (response?.data?.version) { + version = String(response.data.version); + } + if (version) { + this.versionCache.set(Number(viewId), version); + } + } + + ifMatchHeaders(viewId, ifMatch) { + if (!ifMatch) return undefined; + const value = ifMatch === 'auto' ? this.versionCache.get(Number(viewId)) : ifMatch; + if (!value) { + logger.warn(`If-Match: 'auto' requested but no cached version for view ${viewId} — skipping precondition.`); + return undefined; + } + const quoted = /^".*"$/.test(value) ? value : `"${value}"`; + return { headers: { 'If-Match': quoted } }; + } +} + +function requireViewId(id) { + if (!Number.isInteger(id) || id <= 0) { + throw new Error('id (positive integer view id) is required.'); + } +} + +function requireAreaSlot(area, slot) { + if (!area) throw new Error('area is required.'); + if (!slot) throw new Error('slot is required.'); +} + +function encodeArea(area) { + // Layout-builder areas embed `::` separators that WP REST routes + // tolerate either pre-encoded (%3A%3A) or literal. Use literal to + // keep URLs readable; the server's regex covers both forms. + return String(area) + .split('/') + .map((part) => encodeURIComponent(part).replace(/%3A%3A/g, '::')) + .join('/'); +} + +function stripUndefined(obj) { + const out = {}; + for (const [k, v] of Object.entries(obj || {})) { + if (v !== undefined) out[k] = v; + } + return out; +} + +export default GravityViewClient; diff --git a/src/index.js b/src/index.js index 10ae4ab..6bd414c 100644 --- a/src/index.js +++ b/src/index.js @@ -22,6 +22,12 @@ import FieldAwareValidator from './config/field-validation.js'; import logger from './utils/logger.js'; import { sanitize } from './utils/sanitize.js'; import { stripEmpty, stripEntryMetaFromResponse } from './utils/compact.js'; +import { GravityViewClient } from './gravityview-client.js'; +import { + createViewOperations, + viewToolDefinitions, + buildViewToolHandlers, +} from './view-operations/index.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -42,7 +48,7 @@ const server = new Server( capabilities: { tools: {} }, - instructions: 'GravityKit MCP server for Gravity Forms. All responses strip null and empty values by default for minimal token usage. Pass compact=false on any tool to get full raw data. Entry tools also strip plugin-added meta keys; use compact=false to include them.' + instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields) and gv_* for GravityView Views.\n\nGravityView authoring flow: 1) gv_create_view to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_create_grid_row (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_apply_view_config for bulk one-shot writes, or gv_add_view_field / gv_patch_view_field / gv_move_view_field for surgical edits. 4) For Search Bar internal layout, use gv_add_search_field / gv_patch_search_field / gv_remove_search_field — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_list_templates, gv_list_widgets, gv_list_grid_row_types, gv_list_widget_zones (header/footer), gv_list_search_zones (search-general/search-advanced), gv_list_available_fields. Schema: gv_get_field_type_schema works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_move_view_field accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' } ); @@ -50,6 +56,9 @@ const server = new Server( let gravityFormsClient = null; let fieldOperations = null; let fieldValidator = null; +let gravityViewClient = null; +let viewOperations = null; +let viewToolHandlers = null; /** * Initialize Gravity Forms client @@ -73,6 +82,27 @@ async function initializeClient() { logger.info('✅ GravityKit MCP initialized successfully'); logger.info('✅ Field operations infrastructure initialized'); + + // GravityView Inspector client — separate WP REST namespace + // (`/wp-json/gravityview/v1/*`) so the credentials and base + // URL are resolved independently of the GF REST endpoint. The + // constructor allows credential fallback to GRAVITY_FORMS_* + // env vars so single-WP-install setups don't need to mint + // two separate app passwords. + try { + gravityViewClient = new GravityViewClient(process.env); + viewOperations = createViewOperations(gravityViewClient); + viewToolHandlers = buildViewToolHandlers(viewOperations); + logger.info('✅ GravityView client initialized — gv_* tools available'); + } catch (gvError) { + // Don't fail the whole MCP if GravityView credentials are + // missing — gf_* tools still work standalone. + logger.warn(`⚠️ GravityView client unavailable: ${gvError.message}`); + gravityViewClient = null; + viewOperations = null; + viewToolHandlers = null; + } + return true; } catch (error) { logger.error(`❌ Failed to initialize: ${error.message}`); @@ -130,6 +160,40 @@ function wrapHandler(handler, params = {}) { }; } +/** + * Variant of wrapHandler for gv_* tools. Differs in two ways: + * - Checks gravityViewClient (not gravityFormsClient). + * - Surfaces the inspector REST envelope (`{ code, message, data }`) + * so the agent sees `gv_rest_invalid_template` etc. instead of a + * generic "Request failed with status code 400". The inspector's + * errors are designed for AI consumption — preserve them. + */ +function wrapViewHandler(handler, params = {}) { + return async () => { + if (!gravityViewClient) { + return createErrorResponse('GravityView client not initialized'); + } + try { + const result = await handler(); + const output = params.compact !== false ? stripEmpty(result) : result; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + }; + } catch (error) { + // Axios errors carry response.data — when the server speaks + // the inspector REST envelope, that's the most useful payload. + const restBody = error?.response?.data; + const status = error?.response?.status; + const message = restBody?.message || error.message; + const details = restBody + ? { status, code: restBody.code, data: restBody.data } + : undefined; + logger.error(`gv_* tool error: ${message}${status ? ` (HTTP ${status})` : ''}`); + return createErrorResponse(message, details); + } + }; +} + // ================================= // FORMS MANAGEMENT TOOLS (6) // ================================= @@ -540,7 +604,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, // Field Operations (4 tools) - Intelligent field management - ...fieldOperationTools + ...fieldOperationTools, + + // GravityView Inspector (~20 tools) - View configuration CRUD + ...viewToolDefinitions ] }; }); @@ -659,7 +726,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return await fieldOperationHandlers.gf_list_field_types(params, fieldOperations); }, params)(); + // GravityView Inspector — every gv_* tool routes through the + // shared handler map. Single dispatch instead of 20 cases keeps + // the switch readable and makes adding/removing tools a one-line + // change in view-operations/index.js. default: + if (typeof name === 'string' && name.startsWith('gv_')) { + if (!viewToolHandlers || !gravityViewClient) { + return createErrorResponse( + 'GravityView client not initialized. Set GRAVITYVIEW_BASE_URL + GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD in .env (or reuse GRAVITY_FORMS_BASE_URL / GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET when the same WP install hosts both surfaces).' + ); + } + const handler = viewToolHandlers[name]; + if (!handler) { + return createErrorResponse(`Unknown GravityView tool: ${name}`); + } + return wrapViewHandler(() => handler(params), params)(); + } return createErrorResponse(`Unknown tool: ${name}`); } }); @@ -670,10 +753,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { async function main() { try { - // Initialize client on startup - await initializeClient(); - - // Create stdio transport + // Create stdio transport — client initialization is deferred to first tool call + // so the MCP handshake completes instantly (live site validation can take 3+ seconds) const transport = new StdioServerTransport(); // Connect server to transport diff --git a/src/tests/views.test.js b/src/tests/views.test.js new file mode 100644 index 0000000..e15d2e0 --- /dev/null +++ b/src/tests/views.test.js @@ -0,0 +1,365 @@ +/** + * GravityView Inspector Endpoint Tests + * + * Mirrors `forms.test.js` shape: MockHttpClient for the transport, + * scenarios cover happy path, negative client-side validation, and + * the inspector-specific behaviours (If-Match optimistic-concurrency, + * mode replace/merge, area-key URL encoding, allowDelete guard). + */ + +import { GravityViewClient } from '../gravityview-client.js'; +import { ViewValidator } from '../view-operations/view-validator.js'; +import { + TestRunner, + TestAssert, + MockHttpClient, + MockResponse, + setupTestEnvironment, +} from './helpers.js'; + +const suite = new TestRunner('GravityView Inspector Endpoint Tests'); + +let client; +let mockHttpClient; +let testEnv; + +suite.beforeEach(() => { + testEnv = { + ...setupTestEnvironment(), + // GravityViewClient falls back to GRAVITY_FORMS_* creds, so the + // shared setupTestEnvironment values cover both surfaces. + GRAVITYVIEW_ALLOW_DELETE: 'true', + }; + mockHttpClient = new MockHttpClient(); + client = new GravityViewClient(testEnv); + client.httpClient = mockHttpClient; // bypass real network +}); + +// ==================================================================== +// Construction + auth +// ==================================================================== + +suite.test('Constructor: throws without a base URL', () => { + TestAssert.throws(() => new GravityViewClient({}), 'GRAVITYVIEW_BASE_URL'); +}); + +suite.test('Constructor: throws without credentials', () => { + TestAssert.throws( + () => new GravityViewClient({ GRAVITYVIEW_BASE_URL: 'https://example.com' }), + 'WordPress credentials' + ); +}); + +suite.test('Constructor: builds Basic auth header from WP creds', () => { + const c = new GravityViewClient({ + GRAVITYVIEW_BASE_URL: 'https://example.com', + GRAVITYVIEW_WP_USERNAME: 'admin', + GRAVITYVIEW_WP_APP_PASSWORD: 'abc def ghi jkl', + }); + TestAssert.equal( + c.basicAuth, + 'Basic ' + Buffer.from('admin:abc def ghi jkl').toString('base64') + ); +}); + +suite.test('Constructor: falls back to GRAVITY_FORMS_CONSUMER_KEY/SECRET', () => { + const c = new GravityViewClient({ + GRAVITY_FORMS_BASE_URL: 'https://example.com', + GRAVITY_FORMS_CONSUMER_KEY: 'fk', + GRAVITY_FORMS_CONSUMER_SECRET: 'fs', + }); + TestAssert.equal(c.basicAuth, 'Basic ' + Buffer.from('fk:fs').toString('base64')); +}); + +// ==================================================================== +// Discovery +// ==================================================================== + +suite.test('listTemplates: returns the templates array', async () => { + mockHttpClient.setMockResponse( + 'GET', + '/templates', + new MockResponse({ templates: [{ id: 'default_list' }, { id: 'default_table' }] }) + ); + const data = await client.listTemplates(); + TestAssert.equal(data.templates.length, 2); + TestAssert.equal(data.templates[0].id, 'default_list'); +}); + +suite.test('getFieldTypeSchema: requires field_type', async () => { + await TestAssert.throwsAsync( + () => client.getFieldTypeSchema({}), + 'field_type is required' + ); +}); + +// ==================================================================== +// Create +// ==================================================================== + +suite.test('createView: posts to /views and caches the version from ETag', async () => { + mockHttpClient.setMockResponse( + 'POST', + '/views', + new MockResponse( + { view_id: 42, version: 'v1', template_id: 'default_list', created: true }, + 201, + { etag: '"v1"' } + ) + ); + + const result = await client.createView({ + title: 'Smoke', + form_id: 7, + template_id: 'default_list', + template_settings: { lightbox: true }, + }); + + TestAssert.equal(result.view_id, 42); + TestAssert.equal(client.versionCache.get(42), 'v1'); + + const req = mockHttpClient.getRequests().find((r) => r.method === 'POST' && r.path === '/views'); + TestAssert.notEqual(req, undefined); + TestAssert.equal(req.config.data.title, 'Smoke'); + TestAssert.equal(req.config.data.form_id, 7); + // status / mode / search_criteria / fields / widgets shouldn't appear + // when the caller didn't pass them — undefined is stripped. + TestAssert.equal('status' in req.config.data, false); + TestAssert.equal('search_criteria' in req.config.data, false); +}); + +suite.test('createView: rejects non-string title', async () => { + await TestAssert.throwsAsync( + () => client.createView({ form_id: 7 }), + 'title is required' + ); +}); + +suite.test('createView: rejects non-positive form_id', async () => { + await TestAssert.throwsAsync( + () => client.createView({ title: 'x', form_id: 0 }), + 'form_id' + ); +}); + +// ==================================================================== +// Bulk apply + If-Match +// ==================================================================== + +suite.test('applyViewConfig: posts to /views/{id}/config/_apply with the payload', async () => { + mockHttpClient.setMockResponse( + 'POST', + '/views/9/config/_apply', + new MockResponse({ view_id: 9, version: 'v2', template_settings: { page_size: 25 } }) + ); + const result = await client.applyViewConfig({ + id: 9, + template_settings: { page_size: 25 }, + mode: 'merge', + }); + TestAssert.equal(result.template_settings.page_size, 25); + const req = mockHttpClient.getRequests().find((r) => r.path === '/views/9/config/_apply'); + TestAssert.equal(req.config.data.mode, 'merge'); + TestAssert.equal(req.config.data.template_settings.page_size, 25); +}); + +suite.test('applyViewConfig: ifMatch="auto" pulls the cached version into the header', async () => { + // Seed the cache via a read. + mockHttpClient.setMockResponse( + 'GET', + '/views/9/config', + new MockResponse({ view_id: 9, version: 'cached-v3' }, 200, { etag: '"cached-v3"' }) + ); + await client.getViewConfig({ id: 9 }); + TestAssert.equal(client.versionCache.get(9), 'cached-v3'); + + mockHttpClient.setMockResponse( + 'POST', + '/views/9/config/_apply', + new MockResponse({ view_id: 9, version: 'cached-v4' }) + ); + await client.applyViewConfig({ id: 9, template_settings: { page_size: 1 }, ifMatch: 'auto' }); + + const req = mockHttpClient.getRequests().find((r) => r.path === '/views/9/config/_apply'); + TestAssert.equal(req.config.headers['If-Match'], '"cached-v3"'); +}); + +suite.test('applyViewConfig: explicit ifMatch is wrapped in quotes', async () => { + mockHttpClient.setMockResponse( + 'POST', + '/views/9/config/_apply', + new MockResponse({ view_id: 9, version: 'v5' }) + ); + await client.applyViewConfig({ id: 9, template_settings: {}, ifMatch: 'literal' }); + const req = mockHttpClient.getRequests().find((r) => r.path === '/views/9/config/_apply'); + TestAssert.equal(req.config.headers['If-Match'], '"literal"'); +}); + +// ==================================================================== +// Surgical fields +// ==================================================================== + +suite.test('addViewField: requires field.field_id', async () => { + await TestAssert.throwsAsync( + () => client.addViewField({ id: 9, area: 'directory_list-title', field: {} }), + 'field.field_id' + ); +}); + +suite.test('patchViewField: encodes Layout Builder area keys with ::', async () => { + // Area keys like "directory_gravityview-layout-builder-top::100::row_uid" + // contain `::` separators. The client should preserve them so they + // route to the correct InspectorRoute regex. + const area = 'directory_gravityview-layout-builder-top::100::abc123'; + mockHttpClient.setMockResponse( + 'PATCH', + `/views/9/fields/${area}/slot1`, + new MockResponse({ values: { custom_label: 'New' } }) + ); + await client.patchViewField({ id: 9, area, slot: 'slot1', settings: { custom_label: 'New' } }); + const req = mockHttpClient.getRequests().find((r) => r.method === 'PATCH'); + TestAssert.equal(req.path, `/views/9/fields/${area}/slot1`); +}); + +suite.test('moveViewField: posts to /fields/_move with from/to/position', async () => { + mockHttpClient.setMockResponse( + 'POST', + '/views/9/fields/_move', + new MockResponse({ to: { area: 'directory_list-subtitle', slot: 'slot1', position: 0 } }) + ); + await client.moveViewField({ + id: 9, + from: { area: 'directory_list-title', slot: 'slot1' }, + to: { area: 'directory_list-subtitle' }, + position: 0, + }); + const req = mockHttpClient.getRequests().find((r) => r.path === '/views/9/fields/_move'); + TestAssert.equal(req.config.data.from.slot, 'slot1'); + TestAssert.equal(req.config.data.position, 0); +}); + +suite.test('removeViewField: rejected when allowDelete is false', async () => { + client.allowDelete = false; + await TestAssert.throwsAsync( + () => client.removeViewField({ id: 9, area: 'directory_list-title', slot: 'slot1' }), + 'GRAVITYVIEW_ALLOW_DELETE' + ); +}); + +suite.test('removeViewField: deletes when allowDelete is true', async () => { + client.allowDelete = true; + mockHttpClient.setMockResponse( + 'DELETE', + '/views/9/fields/directory_list-title/slot1', + new MockResponse({ deleted: true }) + ); + const result = await client.removeViewField({ id: 9, area: 'directory_list-title', slot: 'slot1' }); + TestAssert.equal(result.deleted, true); +}); + +// ==================================================================== +// renderViewField +// ==================================================================== + +suite.test('renderViewField: GET when no settings overrides supplied', async () => { + mockHttpClient.setMockResponse( + 'GET', + '/views/9/fields/directory_list-title/slot1/render', + new MockResponse({ html: '
rendered
' }) + ); + const r = await client.renderViewField({ id: 9, area: 'directory_list-title', slot: 'slot1' }); + TestAssert.equal(r.html, '
rendered
'); +}); + +suite.test('renderViewField: POST when settings overrides supplied (no persistence)', async () => { + mockHttpClient.setMockResponse( + 'POST', + '/views/9/fields/directory_list-title/slot1/render', + new MockResponse({ html: '
preview
' }) + ); + await client.renderViewField({ + id: 9, + area: 'directory_list-title', + slot: 'slot1', + settings: { custom_label: 'Preview' }, + }); + const req = mockHttpClient.getRequests().find( + (r) => r.method === 'POST' && r.path === '/views/9/fields/directory_list-title/slot1/render' + ); + TestAssert.equal(req.config.data.settings.custom_label, 'Preview'); +}); + +// ==================================================================== +// Validator +// ==================================================================== + +suite.test('Validator: validateApplyPayload accepts a clean payload', () => { + const v = new ViewValidator(client); + v.validateApplyPayload({ + template_id: 'default_list', + template_settings: { page_size: 25 }, + fields: { 'directory_list-title': [{ field_id: '1' }] }, + mode: 'replace', + }); +}); + +suite.test('Validator: rejects unknown mode', () => { + const v = new ViewValidator(client); + TestAssert.throws(() => v.validateApplyPayload({ mode: 'append' }), 'mode must be one of'); +}); + +suite.test('Validator: rejects fields[area] that isn\'t an array', () => { + const v = new ViewValidator(client); + TestAssert.throws( + () => v.validateApplyPayload({ fields: { 'directory_list-title': { field_id: '1' } } }), + 'must be an array' + ); +}); + +suite.test('Validator: rejects field entries missing field_id', () => { + const v = new ViewValidator(client); + TestAssert.throws( + () => v.validateApplyPayload({ fields: { 'directory_list-title': [{ label: 'oops' }] } }), + 'missing required key "field_id"' + ); +}); + +suite.test('Validator: validateAgainstSchemas rejects unknown setting keys', async () => { + // Fake schema for the "custom" field type. + client.getFieldTypeSchema = async () => ({ + field_type: 'custom', + schema: [ + { slug: 'show_label' }, + { slug: 'custom_class' }, + { slug: 'content' }, + ], + }); + const v = new ViewValidator(client); + await TestAssert.throwsAsync( + () => + v.validateAgainstSchemas({ + fields: { + 'directory_list-title': [{ field_id: 'custom', made_up_setting: 'nope' }], + }, + }), + 'unknown setting "made_up_setting"' + ); +}); + +suite.test('Validator: validateAgainstSchemas skips numeric field ids (form fields)', async () => { + // Numeric ids can't be validated via field-type schema — they're + // form fields, not GravityView meta-fields. Validator should + // silently skip them rather than throwing. + let called = 0; + client.getFieldTypeSchema = async () => { + called++; + return { schema: [] }; + }; + const v = new ViewValidator(client); + await v.validateAgainstSchemas({ + fields: { 'directory_list-title': [{ field_id: '1' }, { field_id: '5.2' }] }, + }); + TestAssert.equal(called, 0); +}); + +suite.run(); diff --git a/src/view-operations/index.js b/src/view-operations/index.js new file mode 100644 index 0000000..548c1cf --- /dev/null +++ b/src/view-operations/index.js @@ -0,0 +1,507 @@ +/** + * GravityView Inspector tool surface for the GravityKit MCP. + * + * Exports: + * - createViewOperations(client) → { manager, validator } + * - viewToolDefinitions → JSON Schema for every gv_* tool + * - viewToolHandlers → { tool_name: async (params, ctx) => result } + * + * Handlers run client-side validation BEFORE calling the REST API + * so structural mistakes fail with a useful error instead of a 400. + * Schema-aware validation is gated on `validateAgainstSchemas: true` + * because each unique field type costs one network round trip. + */ + +import { ViewValidator } from './view-validator.js'; + +// Shared compact arg used by every tool — mirrors the gf_* convention. +const COMPACT_ARG = { + compact: { type: 'boolean', description: 'Set false for full raw response data', default: true }, +}; + +// Reusable arg shapes. +const VIEW_ID = { type: 'integer', description: 'GravityView post id' }; +const AREA = { + type: 'string', + description: 'Area key in the form `{zone}_{areaid}` (e.g. directory_list-title). Layout Builder templates append `::cols::row_uid` for compound keys.', +}; +const SLOT = { + type: 'string', + description: 'Slot UID (UUID v4 for new slots; legacy slots may use 13-char MD5 hex).', +}; +const IF_MATCH = { + type: 'string', + description: 'Optional optimistic-concurrency token. Pass the `version` from a previous read, or "auto" to use the client-cached version. Server returns 412 on stale.', +}; + +export function createViewOperations(client) { + const validator = new ViewValidator(client); + return { client, validator }; +} + +export const viewToolDefinitions = [ + // -------------------------------------------------------------- Discovery + { + name: 'gv_list_templates', + description: 'List every installed GravityView layout template (id, slug, label, description, logo). Use to discover valid template_id values before creating or switching a View.', + inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, + }, + { + name: 'gv_list_widgets', + description: 'List every registered GravityView widget (id, label, description, icon, class). Use to discover valid widget ids before placing one with gv_add_view_widget. Sourced live from \\GV\\Widget::registered() so third-party widgets surface automatically.', + inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, + }, + { + name: 'gv_list_grid_row_types', + description: 'List every registered Layout Builder row type (100, 50/50, 33/66, 33/33/33, 25/25/25/25, …) with their column structure. Use to discover valid `type` values before calling gv_create_grid_row. Sourced live from \\GV\\Grid::get_row_types() so add-on row layouts surface automatically.', + inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, + }, + { + name: 'gv_list_widget_zones', + description: 'List the widget meta-zones (header, footer). Use as the `zones` param for surface=widgets grid CRUD. Note: visible zone names like "header_top" / "header_left" are zone+column combinations — pick the meta-zone here, then the row type determines the columns.', + inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, + }, + { + name: 'gv_list_search_zones', + description: 'List the Search Bar internal zones (search-general, search-advanced). Filterable via `gk/gravityview/rest/search-zones` for add-ons.', + inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, + }, + { + name: 'gv_list_view_forms', + description: 'List Gravity Forms forms exposed via the GravityView Inspector REST surface. Lighter than gf_list_forms (returns just id/title/fields count). For full form details use gf_get_form.', + inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, + }, + { + name: 'gv_get_field_type_schema', + description: 'Get the settings schema for a GravityView field type (e.g. "custom", "entry_link", "text"). No View id required — useful for AI agents authoring a fresh View.', + inputSchema: { + type: 'object', + properties: { + field_type: { type: 'string', description: 'Field type slug (e.g. "custom", "entry_link"). For form-bound fields (numeric ids), use gv_get_view_field_schemas instead.' }, + template_id: { type: 'string', description: 'Optional layout template id; affects which settings the schema includes. Defaults to default_list.' }, + context: { type: 'string', enum: ['multiple', 'single', 'edit', 'search'], description: 'Render context. Defaults to multiple.' }, + input_type: { type: 'string', description: 'Optional GF input type (textarea, select, …) for form-bound fields.' }, + form_id: { type: 'integer', description: 'Optional form id for input-type detection.' }, + ...COMPACT_ARG, + }, + required: ['field_type'], + }, + }, + + // --------------------------------------------------------------- Per-view reads + { + name: 'gv_get_view_config', + description: 'Read the full View configuration tree (template, fields, widgets, template_settings, search_criteria, version). Returns the version that subsequent writes can pass via ifMatch.', + inputSchema: { type: 'object', properties: { id: VIEW_ID, ...COMPACT_ARG }, required: ['id'] }, + }, + { + name: 'gv_get_view_areas', + description: 'Inventory of zones (directory / single / edit) and area ids the View\'s template exposes. Tells you the valid `area` keys for adding/moving fields.', + inputSchema: { type: 'object', properties: { id: VIEW_ID, ...COMPACT_ARG }, required: ['id'] }, + }, + { + name: 'gv_list_available_fields', + description: 'Form fields placeable into the View, plus GravityView meta-fields (custom content, entry link, edit link, etc.). Use the returned ids as `field_id` when adding fields.', + inputSchema: { type: 'object', properties: { id: VIEW_ID, ...COMPACT_ARG }, required: ['id'] }, + }, + { + name: 'gv_get_view_field_schemas', + description: 'Bulk schema for every CONFIGURED slot in the View — `{area/slot}` → settings schema. One call instead of N gv_get_field_type_schema calls.', + inputSchema: { type: 'object', properties: { id: VIEW_ID, ...COMPACT_ARG }, required: ['id'] }, + }, + { + name: 'gv_render_view_field', + description: 'Render a single configured slot to HTML. Pass `settings` to preview an in-flight settings change WITHOUT persisting (server renders in-memory). Use to verify a format/conditional-logic change before committing.', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + area: AREA, + slot: SLOT, + settings: { type: 'object', description: 'Optional staged settings overrides. Persists nothing.' }, + ...COMPACT_ARG, + }, + required: ['id', 'area', 'slot'], + }, + }, + + // --------------------------------------------------------------- Create + { + name: 'gv_create_view', + description: 'Create a draft GravityView View, optionally seeded with template settings + fields + widgets in one shot. Returns the full config envelope (including `view_id`, `version`, and `admin_url`) so no follow-up GET is needed.', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'View title (post title).' }, + form_id: { type: 'integer', description: 'Source Gravity Forms form id. Use gf_list_forms to discover.' }, + template_id: { type: 'string', description: 'Layout template for the directory zone (Multiple Entries listing). Defaults to gravityview-layout-builder. Use gv_list_templates for the catalogue.' }, + template_ids: { + type: 'object', + description: 'Optional per-zone template overrides — { single?, edit? }. Multiple Entries (directory) and Single Entry can use different layouts (e.g. directory: gravityview-layout-builder, single: default_table). Single defaults to directory; edit follows directory unless explicitly set.', + }, + status: { type: 'string', enum: ['draft', 'publish', 'pending', 'private'], description: 'Initial post status. Defaults to draft.' }, + template_settings: { type: 'object', description: 'Optional initial template_settings (page_size, lightbox, etc.).' }, + search_criteria: { type: 'object', description: 'Optional initial search_criteria (sort, pagination defaults).' }, + fields: { type: 'object', description: 'Optional initial field tree, keyed by area key. Each value is an ordered array of `{ field_id, label?, slot?, …settings }` objects.' }, + widgets: { type: 'object', description: 'Optional initial widget tree (same shape as fields).' }, + mode: { type: 'string', enum: ['replace', 'merge'], description: 'Apply mode for fields/widgets. Default: replace.' }, + validateAgainstSchemas: { type: 'boolean', description: 'When true, fetches each referenced field type\'s schema and rejects unknown setting keys before sending. Costs extra round trips. Default: false.' }, + ...COMPACT_ARG, + }, + required: ['title', 'form_id'], + }, + }, + + // --------------------------------------------------------------- Bulk apply + { + name: 'gv_apply_view_config', + description: 'Bulk apply template + settings + ordered fields + widgets to an existing View in one round trip. Server-side mode: replace (default — each area in payload replaces existing area) or merge (additive). Pass ifMatch to enforce optimistic concurrency.', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + template_id: { type: 'string', description: 'Switch the directory-zone template before applying fields/widgets. Equivalent to gv_set_view_template (zone=directory) + the apply.' }, + template_ids: { + type: 'object', + description: 'Optional per-zone template overrides — { single?, edit? }. Same shape as gv_create_view. Pass an empty string for a zone to clear its override (falls back to directory).', + }, + template_settings: { type: 'object', description: 'Partial-merge into template_settings.' }, + search_criteria: { type: 'object', description: 'Pagination + sort defaults. Persisted into template_settings.' }, + fields: { type: 'object', description: 'Ordered field arrays per area key.' }, + widgets: { type: 'object', description: 'Ordered widget arrays per area key.' }, + mode: { type: 'string', enum: ['replace', 'merge'], description: 'replace = each area in fields/widgets replaces existing. merge = additive. Default: replace.' }, + ifMatch: IF_MATCH, + validateAgainstSchemas: { type: 'boolean', description: 'When true, fetches each referenced field type\'s schema and rejects unknown setting keys before sending. Default: false.' }, + ...COMPACT_ARG, + }, + required: ['id'], + }, + }, + + // --------------------------------------------------------------- Surgical settings + template + { + name: 'gv_set_view_template', + description: 'Switch a View zone\'s layout template. The directory zone (Multiple Entries) is the default; pass `zone: "single"` for Single Entry or `zone: "edit"` for Edit Entry. Discard policy controls whether the affected zone\'s existing field/widget placements survive the switch.', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + template_id: { type: 'string', description: 'New template id. Use gv_list_templates to discover.' }, + zone: { type: 'string', enum: ['directory', 'single', 'edit'], description: 'Zone to switch. Defaults to directory.' }, + policy: { type: 'string', enum: ['discard', 'keep'], description: 'discard (default) clears the zone\'s field+widget placements so they don\'t reference the old template\'s areas; keep preserves them at the risk of orphan placements.' }, + ifMatch: IF_MATCH, + ...COMPACT_ARG, + }, + required: ['id', 'template_id'], + }, + }, + { + name: 'gv_patch_view_settings', + description: 'Partial-merge into template_settings (page_size, lightbox, show_only_approved, etc.). Other settings preserved.', + inputSchema: { + type: 'object', + properties: { id: VIEW_ID, template_settings: { type: 'object' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, + required: ['id', 'template_settings'], + }, + }, + { + name: 'gv_patch_view_search_criteria', + description: 'Partial-merge into search_criteria (sort_field, sort_direction, page_size). Persisted into template_settings.', + inputSchema: { + type: 'object', + properties: { id: VIEW_ID, search_criteria: { type: 'object' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, + required: ['id', 'search_criteria'], + }, + }, + + // --------------------------------------------------------------- Surgical field ops + { + name: 'gv_add_view_field', + description: 'Add a single field slot to an area. Server mints the slot UID and returns it. For multi-field changes prefer gv_apply_view_config with mode=merge.', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + area: AREA, + field: { + type: 'object', + description: '{ field_id (required), label?, custom_label?, …settings }', + required: ['field_id'], + }, + ifMatch: IF_MATCH, + ...COMPACT_ARG, + }, + required: ['id', 'area', 'field'], + }, + }, + { + name: 'gv_patch_view_field', + description: 'Patch a single field slot\'s settings. Only keys present in `settings` are updated; rest preserved. Use for cosmetic edits (custom_label, custom_class) and per-field options (show_label, only_loggedin, etc.).', + inputSchema: { + type: 'object', + properties: { id: VIEW_ID, area: AREA, slot: SLOT, settings: { type: 'object' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, + required: ['id', 'area', 'slot', 'settings'], + }, + }, + { + name: 'gv_move_view_field', + description: 'Move a field across areas, or reorder within the same area. The moved slot keeps its UID. Placement precedence in `to`: `before_slot` > `after_slot` > `position`. Use ref-relative placement ("place this field BEFORE the Title") instead of counting positions when possible — slot UIDs are stable across other moves.', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + from: { type: 'object', properties: { area: AREA, slot: SLOT }, required: ['area', 'slot'] }, + to: { + type: 'object', + properties: { + area: AREA, + before_slot: { type: 'string', description: 'Insert immediately BEFORE this slot UID (preferred when known).' }, + after_slot: { type: 'string', description: 'Insert immediately AFTER this slot UID.' }, + }, + required: ['area'], + }, + position: { description: 'Symbolic ("start" | "end") or zero-based integer. Defaults to "end". Negative integers append. Ignored when before_slot / after_slot is set.' }, + ifMatch: IF_MATCH, + ...COMPACT_ARG, + }, + required: ['id', 'from', 'to'], + }, + }, + { + name: 'gv_remove_view_field', + description: 'Delete a single field slot. Requires GRAVITYVIEW_ALLOW_DELETE=true (or GRAVITY_FORMS_ALLOW_DELETE=true) in the MCP env to guard against accidents.', + inputSchema: { + type: 'object', + properties: { id: VIEW_ID, area: AREA, slot: SLOT, ifMatch: IF_MATCH, ...COMPACT_ARG }, + required: ['id', 'area', 'slot'], + }, + }, + + // --------------------------------------------------------------- Surgical widget ops + { + name: 'gv_add_view_widget', + description: 'Add a single widget slot to a widget area. Use gv_list_widgets to discover valid widget.field_id values (search_bar, page_links, page_info, custom_content, poll, gravityforms, etc.). For widget-specific settings, fetch gv_get_field_type_schema with the widget id (e.g. search_bar returns search_layout, search_fields, search_clear, search_mode, sieve_choices).', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + area: { type: 'string', description: 'Widget area key. Fixed list across templates: header_top, header_bottom, header_left, header_right, footer_top, footer_bottom, footer_left, footer_right.' }, + widget: { type: 'object', required: ['field_id'] }, + ifMatch: IF_MATCH, + ...COMPACT_ARG, + }, + required: ['id', 'area', 'widget'], + }, + }, + { + name: 'gv_patch_view_widget', + description: 'Patch a single widget slot\'s settings.', + inputSchema: { + type: 'object', + properties: { id: VIEW_ID, area: { type: 'string' }, slot: SLOT, settings: { type: 'object' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, + required: ['id', 'area', 'slot', 'settings'], + }, + }, + { + name: 'gv_remove_view_widget', + description: 'Delete a single widget slot. Requires GRAVITYVIEW_ALLOW_DELETE=true.', + inputSchema: { + type: 'object', + properties: { id: VIEW_ID, area: { type: 'string' }, slot: SLOT, ifMatch: IF_MATCH, ...COMPACT_ARG }, + required: ['id', 'area', 'slot'], + }, + }, + + // --------------------------------------------------------------- Grid (any surface) + { + name: 'gv_create_grid_row', + description: 'Add a grid row to one of the View\'s grid surfaces. surface=fields (default) targets the View\'s main field tree per zone (directory|single, prefixed by per-zone Layout Builder template). surface=widgets targets header / footer widget zones. Returns the new row_uid + materialised areaids per zone — use those areaids when placing fields/widgets via gv_apply_view_config. Use gv_list_grid_row_types for valid `type` values; gv_list_widget_zones for widget meta-zones.', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + surface: { type: 'string', enum: ['fields', 'widgets'], description: 'fields = View main field tree (default). widgets = header/footer widget zones.' }, + type: { type: 'string', description: 'Row type. Defaults to "100" (full width). Use gv_list_grid_row_types to discover the live catalogue (100, 50/50, 33/66, 33/33/33, 25/25/25/25, …).' }, + zones: { type: 'array', description: 'Zones to materialise the row in. surface=fields default: [directory, single]. surface=widgets default: [header, footer].', items: { type: 'string' } }, + ifMatch: IF_MATCH, + ...COMPACT_ARG, + }, + required: ['id'], + }, + }, + { + name: 'gv_patch_grid_row', + description: 'Re-key every field/widget in a grid row from one type to another (e.g. resize 100 → 50/50, or 33/33/33 → 50/50). When the new type has fewer columns, surplus items collapse into the first column rather than vanishing. Pass the same `surface` you used to create the row.', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + surface: { type: 'string', enum: ['fields', 'widgets'], description: 'Defaults to fields.' }, + row_uid: { type: 'string', description: 'Row UID returned by gv_create_grid_row or visible in gv_get_view_areas response.' }, + type: { type: 'string', description: 'New row type (see gv_list_grid_row_types).' }, + ifMatch: IF_MATCH, + ...COMPACT_ARG, + }, + required: ['id', 'row_uid', 'type'], + }, + }, + { + name: 'gv_delete_grid_row', + description: 'Remove a grid row and every field/widget placed in any of its areas on the targeted surface. Requires GRAVITYVIEW_ALLOW_DELETE=true.', + inputSchema: { + type: 'object', + properties: { id: VIEW_ID, surface: { type: 'string', enum: ['fields', 'widgets'] }, row_uid: { type: 'string' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, + required: ['id', 'row_uid'], + }, + }, + + // --------------------------------------------------------------- Search Bar internal slot CRUD (modern shape) + { + name: 'gv_add_search_field', + description: 'Add a Search Field inside a search_bar widget\'s modern search_fields_section. Identify the parent widget via widget_area + widget_slot (find them with gv_get_view_config; widget_area is a key under `widgets`, widget_slot is the search_bar\'s slot UID). The position string is `{search_zone}_{areaid}::{type}::{row_uid}` — get search_zone from gv_list_search_zones, build a row_uid + type via gv_create_grid_row (surface=widgets) if needed.\n\nfield.id options: "search_all" (free text), "submit", "search_mode", "created_by", "is_starred", "is_read", or a GF field id (e.g. "3"). field.input: input_text, select, multiselect, radio, checkbox, single_checkbox, date, date_range, number_range, link, hidden, submit. Use gv_get_field_type_schema with the search_field type for the full settings catalogue.', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + widget_area: { type: 'string', description: 'Widget area key (e.g. "header_top::100::ROW_UID").' }, + widget_slot: { type: 'string', description: 'Search bar widget\'s slot UID.' }, + position: { type: 'string', description: 'Search-bar internal position: `{search_zone}_{areaid}::{type}::{row_uid}`.' }, + field: { + type: 'object', + description: '{ id (required), type, input, label?, show_label?, ...settings }', + required: ['id'], + }, + slot: { type: 'string', description: 'Optional search slot UID. Auto-minted when omitted.' }, + ifMatch: IF_MATCH, + ...COMPACT_ARG, + }, + required: ['id', 'widget_area', 'widget_slot', 'position', 'field'], + }, + }, + { + name: 'gv_patch_search_field', + description: 'Patch settings on an existing search field slot. Settings keys present in the payload overwrite; null values delete that key.', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + widget_area: { type: 'string' }, + widget_slot: { type: 'string' }, + position: { type: 'string' }, + search_slot: { type: 'string' }, + settings: { type: 'object' }, + ifMatch: IF_MATCH, + ...COMPACT_ARG, + }, + required: ['id', 'widget_area', 'widget_slot', 'position', 'search_slot', 'settings'], + }, + }, + { + name: 'gv_remove_search_field', + description: 'Remove a search field slot from a search_bar widget\'s search_fields_section. Requires GRAVITYVIEW_ALLOW_DELETE=true.', + inputSchema: { + type: 'object', + properties: { + id: VIEW_ID, + widget_area: { type: 'string' }, + widget_slot: { type: 'string' }, + position: { type: 'string' }, + search_slot: { type: 'string' }, + ifMatch: IF_MATCH, + ...COMPACT_ARG, + }, + required: ['id', 'widget_area', 'widget_slot', 'position', 'search_slot'], + }, + }, +]; + +/** + * Build the handler map. Returns `{ tool_name: async (params) => raw_result }`. + * The MCP transport layer handles compaction + content envelope wrapping. + */ +export function buildViewToolHandlers({ client, validator }) { + return { + // Discovery + gv_list_templates: () => client.listTemplates(), + gv_list_widgets: () => client.listWidgets(), + gv_list_grid_row_types: () => client.listGridRowTypes(), + gv_list_widget_zones: () => client.listWidgetZones(), + gv_list_search_zones: () => client.listSearchZones(), + gv_list_view_forms: () => client.listForms(), + gv_get_field_type_schema: (params) => client.getFieldTypeSchema(params), + + // Reads + gv_get_view_config: (params) => client.getViewConfig(params), + gv_get_view_areas: (params) => client.getViewAreas(params), + gv_list_available_fields: (params) => client.listAvailableFields(params), + gv_get_view_field_schemas: (params) => client.getViewFieldSchemas(params), + gv_render_view_field: (params) => client.renderViewField(params), + + // Create + gv_create_view: async (params) => { + validator.validateCreatePayload(params); + if (params.validateAgainstSchemas) { + await validator.validateAgainstSchemas({ + fields: params.fields || {}, + widgets: params.widgets || {}, + template_id: params.template_id, + }); + } + // Layout Builder area validation skipped on create — the View + // doesn't exist yet, so its grid hasn't been materialised. + const { validateAgainstSchemas, compact, ...payload } = params; + return client.createView(payload); + }, + + // Bulk apply + gv_apply_view_config: async (params) => { + validator.validateApplyPayload(params); + if (params.validateAgainstSchemas) { + await validator.validateAgainstSchemas({ + fields: params.fields || {}, + widgets: params.widgets || {}, + template_id: params.template_id, + }); + } + // Layout Builder area validation: when any of the area keys + // contain `::` (the Layout Builder compound form), confirm + // they exist in the View's current grid before sending. + // Cheap (one extra GET) and saves a 400 round trip on typos. + await validator.validateLayoutBuilderAreas({ + id: params.id, + fields: params.fields || {}, + widgets: params.widgets || {}, + }); + const { validateAgainstSchemas, compact, ...payload } = params; + return client.applyViewConfig(payload); + }, + + // Surgical settings + template + gv_set_view_template: (params) => client.setViewTemplate(params), + gv_patch_view_settings: (params) => client.patchViewSettings(params), + gv_patch_view_search_criteria: (params) => client.patchViewSearchCriteria(params), + + // Surgical field ops + gv_add_view_field: (params) => client.addViewField(params), + gv_patch_view_field: (params) => client.patchViewField(params), + gv_move_view_field: (params) => client.moveViewField(params), + gv_remove_view_field: (params) => client.removeViewField(params), + + // Surgical widget ops + gv_add_view_widget: (params) => client.addViewWidget(params), + gv_patch_view_widget: (params) => client.patchViewWidget(params), + gv_remove_view_widget: (params) => client.removeViewWidget(params), + + // Grid CRUD (any surface — fields | widgets) + gv_create_grid_row: (params) => client.addGridRow(params), + gv_patch_grid_row: (params) => client.patchGridRow(params), + gv_delete_grid_row: (params) => client.deleteGridRow(params), + + // Search Bar internal slot CRUD (modern shape) + gv_add_search_field: (params) => client.addSearchField(params), + gv_patch_search_field: (params) => client.patchSearchField(params), + gv_remove_search_field: (params) => client.removeSearchField(params), + }; +} + +export { ViewValidator }; diff --git a/src/view-operations/view-validator.js b/src/view-operations/view-validator.js new file mode 100644 index 0000000..8d050e6 --- /dev/null +++ b/src/view-operations/view-validator.js @@ -0,0 +1,253 @@ +/** + * Client-side validator for the inspector REST surface. + * + * The server is the source of truth (every write runs through + * `apply_collection` and per-setting sanitisers in InspectorRoute), + * but failing fast on the obvious structural mistakes saves a round + * trip and gives the agent a more useful error message — "field_id + * is required on every entry in fields[]" beats a 400 with + * `gv_rest_invalid_field`. + * + * Validation tiers: + * - structural (free): required keys, types, enums, mode + * - schema-aware (one fetch): per-field-type setting validation + * against the live `/field-types/{type}/schema` response + * + * Schema-aware checks are opt-in (`{ deep: true }`) because they + * cost a network round trip per unique field type referenced. + */ + +const VALID_MODES = ['replace', 'merge']; + +export class ViewValidator { + constructor(client) { + this.client = client; + // Cache field-type schemas across calls so a payload touching + // ten `text` fields only fetches the schema once. + this.schemaCache = new Map(); + } + + /** + * Structural validation of an apply payload. Throws on the first + * issue with a message the AI agent can use to correct the call. + */ + validateApplyPayload(payload = {}) { + if (typeof payload !== 'object' || payload === null) { + throw new Error('apply payload must be an object.'); + } + + if (payload.mode !== undefined && !VALID_MODES.includes(payload.mode)) { + throw new Error(`mode must be one of: ${VALID_MODES.join(', ')}`); + } + + if (payload.fields !== undefined) { + this.validateAreaTree('fields', payload.fields); + } + if (payload.widgets !== undefined) { + this.validateAreaTree('widgets', payload.widgets); + } + + if (payload.template_id !== undefined && typeof payload.template_id !== 'string') { + throw new Error('template_id must be a string.'); + } + + if (payload.template_settings !== undefined && (typeof payload.template_settings !== 'object' || payload.template_settings === null)) { + throw new Error('template_settings must be an object.'); + } + + if (payload.search_criteria !== undefined && (typeof payload.search_criteria !== 'object' || payload.search_criteria === null)) { + throw new Error('search_criteria must be an object.'); + } + } + + /** + * Validate the create-View payload before it leaves the client. + * The server enforces the same checks but returning early avoids + * a 400 round trip for typos. + */ + validateCreatePayload(payload = {}) { + if (!payload.title || typeof payload.title !== 'string') { + throw new Error('title (non-empty string) is required.'); + } + if (!Number.isInteger(payload.form_id) || payload.form_id <= 0) { + throw new Error('form_id (positive integer) is required.'); + } + if (payload.template_id !== undefined && typeof payload.template_id !== 'string') { + throw new Error('template_id must be a string.'); + } + // Reuse the apply-payload structural checks for the seed bits. + this.validateApplyPayload({ + template_id: payload.template_id, + template_settings: payload.template_settings, + search_criteria: payload.search_criteria, + fields: payload.fields, + widgets: payload.widgets, + mode: payload.mode, + }); + } + + validateAreaTree(label, tree) { + if (typeof tree !== 'object' || tree === null) { + throw new Error(`${label} must be an object keyed by area key.`); + } + for (const [area, items] of Object.entries(tree)) { + if (typeof area !== 'string' || area === '') { + throw new Error(`${label} contains a non-string area key.`); + } + if (!Array.isArray(items)) { + throw new Error(`${label}["${area}"] must be an array of slot objects.`); + } + items.forEach((item, idx) => { + if (!item || typeof item !== 'object') { + throw new Error(`${label}["${area}"][${idx}] must be an object.`); + } + if (!('field_id' in item) || item.field_id === '' || item.field_id === null) { + throw new Error(`${label}["${area}"][${idx}] is missing required key "field_id".`); + } + if (item.slot !== undefined && typeof item.slot !== 'string') { + throw new Error(`${label}["${area}"][${idx}].slot must be a string when present.`); + } + }); + } + } + + /** + * Schema-aware validation. Walks every field AND every widget in + * the payload, fetches the matching field-type schema (cached), + * and confirms the settings keys are recognised. + * + * Widgets dispatch to widget-specific schemas (post the InspectorRoute + * fix that detects registered widgets). Fields whose `field_id` is a + * numeric/composite GF id are skipped — those settings live in the + * View-specific bulk-schema endpoint, not the field-type registry. + * + * Pass `{ template_id, context }` so the schema fetch matches the + * setting set the inspector actually persists for that combination. + */ + async validateAgainstSchemas({ fields = {}, widgets = {}, template_id, context }) { + const allEntries = [ + ...Object.values(fields).flatMap((arr) => arr.map((item) => ({ kind: 'field', item }))), + ...Object.values(widgets).flatMap((arr) => arr.map((item) => ({ kind: 'widget', item }))), + ]; + + for (const { kind, item } of allEntries) { + // Widgets identify themselves through field_id too — InspectorRoute + // detects when the type maps to a registered widget and returns + // the widget's settings schema (e.g. search_bar → search_layout, + // search_fields, …). Skipping schema validation for widgets would + // miss the most common authoring mistake (typoed setting key). + const typeSlug = guessFieldType(item.field_id); + if (!typeSlug) continue; + + const schema = await this.getSchema(typeSlug, template_id, context); + if (!schema || !Array.isArray(schema.schema)) continue; + + const validKeys = new Set(schema.schema.map((entry) => entry.slug)); + // `field_id`, `slot`, `label`, `id`, `custom_label`, `custom_class` + // are always accepted by the server alongside whatever the schema + // lists — they're meta-keys persisted independently of per-type + // settings. + const reservedKeys = new Set([ + 'field_id', + 'slot', + 'label', + 'id', + 'custom_label', + 'custom_class', + ]); + + for (const key of Object.keys(item)) { + if (reservedKeys.has(key)) continue; + if (!validKeys.has(key)) { + const samples = [...validKeys].slice(0, 12).join(', '); + const more = validKeys.size > 12 ? ', …' : ''; + throw new Error( + `${kind} "${item.field_id}" has unknown setting "${key}". Schema for ${schema.kind || 'type'} "${typeSlug}" lists: ${samples}${more}` + ); + } + } + } + } + + /** + * When the payload places fields into Layout Builder area keys + * (compound `{prefix}-{areaid}::{type}::{row_uid}` form), confirm + * the referenced row_uids exist in the View's current grid. Without + * this check, a typoed row_uid silently lands the field somewhere + * the inspector can't render. + * + * Cheap: one `gv_get_view_areas` call regardless of payload size. + * No-op when none of the area keys look like Layout Builder keys. + */ + async validateLayoutBuilderAreas({ id, fields = {}, widgets = {} }) { + const allAreas = Object.keys({ ...fields, ...widgets }); + const lbAreas = allAreas.filter((key) => key.includes('::')); + if (!id || lbAreas.length === 0) return; + + let known; + try { + const areas = await this.client.getViewAreas({ id }); + const zoneMap = areas?.zones || {}; + known = new Set(); + for (const zone of Object.keys(zoneMap)) { + for (const row of zoneMap[zone] || []) { + for (const area of row.areas || []) { + if (area.areaid) { + known.add(`${zone}_${area.areaid}`); + } + } + } + } + } catch (err) { + // Areas fetch failed (network, perms, missing template) — degrade + // to no-op rather than blocking the write. + return; + } + + for (const areaKey of lbAreas) { + if (!known.has(areaKey)) { + const sample = [...known].slice(0, 4).join(', '); + throw new Error( + `Area "${areaKey}" doesn't exist in this View's grid. Use gv_get_view_areas to discover valid areas, or gv_create_grid_row to add a new row first. Known areas include: ${sample}…` + ); + } + } + } + + async getSchema(fieldType, template_id, context) { + const key = `${fieldType}|${template_id || ''}|${context || ''}`; + if (this.schemaCache.has(key)) return this.schemaCache.get(key); + try { + const data = await this.client.getFieldTypeSchema({ field_type: fieldType, template_id, context }); + this.schemaCache.set(key, data); + return data; + } catch (error) { + // Schema discovery failures shouldn't block writes — degrade + // to structural-only validation. Cache the failure so we don't + // re-fetch on every field with the same type. + this.schemaCache.set(key, null); + return null; + } + } +} + +/** + * Best-effort field-type inference from a `field_id`. The inspector + * REST API uses the same identifier for both numeric form fields + * (e.g. "1", "5.2") and meta-fields (e.g. "custom", "entry_link", + * "edit_link"). Numeric ids are form fields and could be any type; + * non-numeric ids ARE the type slug. For numeric ids we return null + * because validating them needs the form schema, not the field-type + * schema (different API). + */ +function guessFieldType(fieldId) { + if (fieldId === undefined || fieldId === null) return null; + const str = String(fieldId).trim(); + if (str === '') return null; + // Numeric (incl. composite like "5.2") — we can't determine type + // from the id alone. Skip schema-aware validation for these. + if (/^\d+(\.\d+)?$/.test(str)) return null; + return str; +} + +export default ViewValidator; From 2458046fc16b779923c5b1fe52aea4a4644a3664 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 14 May 2026 09:32:28 -0400 Subject: [PATCH 02/36] feat(gv): auto-generate gv_* tools from the Abilities API catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-maintained viewToolDefinitions array with a dynamic loader: on MCP startup, fetch /wp-json/wp-abilities/v1/abilities, filter to the gk-gravityview/* namespace, and synthesise a {name, description, inputSchema} tuple per ability. HTTP method is derived from annotations (readonly→GET, destructive→DELETE, otherwise POST). Tool naming is the existing convention: gk-gravityview/list-layouts → gv_list_layouts. The hand-maintained tool defs stay as a fallback for older WP installs without the abilities-api package or when the catalog is unreachable. GET/DELETE inputs ship as bracketed query params (input[key][nested]=v) so WordPress REST rebuilds the nested object straight off the URL — the abilities-api controller otherwise hands ?input=… straight to the schema validator and complains "input is not of type object". Drops the GRAVITYVIEW_ALLOW_DELETE env gate now that no "delete the whole View" ability exists; field/widget/row removal is normal authoring (reversible by re-adding) and status: trash is the only soft-remove path, gated server-side by the WP delete_post capability. The GRAVITY_FORMS_ALLOW_DELETE gate stays for entry/form/feed deletes — those genuinely destroy data. Adds src/tests/views-stress.test.js — 114 live tests against dev.test covering the full catalog: hostile payloads, optimistic-concurrency edge cases, schema-driven validation per setting type, search-field domain delegation, area-key prefix/length/control-char validation, duplicate-view + set-view-status round-trips, and an anti-test proving no permanent-delete ability is registered. Adds demo-abilities.mjs — a cold-start walk through the abilities surface for onboarding + manual smoke tests. --- .env.example | 87 +- .mcp.json | 14 +- AGENTS.md | 40 +- CLAUDE.md | 2 +- README.md | 19 +- demo-abilities.mjs | 183 ++ mcp.json | 8 +- src/gravity-forms-client.js | 4 +- src/gravityview-client.js | 193 +- src/index.js | 52 +- src/tests/views-stress.test.js | 2710 +++++++++++++++++++++++ src/tests/views.test.js | 134 +- src/view-operations/abilities-loader.js | 184 ++ src/view-operations/index.js | 41 +- src/view-operations/view-validator.js | 95 +- 15 files changed, 3589 insertions(+), 177 deletions(-) create mode 100644 demo-abilities.mjs create mode 100644 src/tests/views-stress.test.js create mode 100644 src/view-operations/abilities-loader.js diff --git a/.env.example b/.env.example index 09b2f89..908e01c 100644 --- a/.env.example +++ b/.env.example @@ -1,58 +1,79 @@ # GravityKit MCP Configuration -# Required: Gravity Forms REST API v2 Credentials -# Generate these in your WordPress admin: Forms > Settings > REST API +# ============================================================ +# REQUIRED: Gravity Forms REST API v2 Credentials +# Generate these in WordPress admin: Forms > Settings > REST API +# ============================================================ GRAVITY_FORMS_CONSUMER_KEY=ck_your_consumer_key_here GRAVITY_FORMS_CONSUMER_SECRET=cs_your_consumer_secret_here # Required: Your WordPress site URL (no trailing slash) GRAVITY_FORMS_BASE_URL=https://yoursite.com -# Example for local development: -# GRAVITY_FORMS_CONSUMER_KEY=ck_3f4d5e6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e -# GRAVITY_FORMS_CONSUMER_SECRET=cs_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b -# GRAVITY_FORMS_BASE_URL=https://local.gravityforms.test +# Shorthand aliases also supported (used internally by test-config): +# GF_CONSUMER_KEY=ck_... +# GF_CONSUMER_SECRET=cs_... +# GF_URL=https://yoursite.com -# Example for live site: -# GRAVITY_FORMS_CONSUMER_KEY=ck_production_key_from_wordpress_admin -# GRAVITY_FORMS_CONSUMER_SECRET=cs_production_secret_from_wordpress_admin -# GRAVITY_FORMS_BASE_URL=https://www.yourwebsite.com - -# Authentication Settings -# Authentication method: 'basic' (recommended) or 'oauth' +# ============================================================ +# AUTHENTICATION +# ============================================================ +# Method: 'basic' (default, recommended) or 'oauth' / 'oauth1' +# Basic Auth requires HTTPS. If the site uses HTTP, the server +# silently falls back to OAuth 1.0a (see Gotcha #3 in AGENTS.md). GRAVITY_FORMS_AUTH_METHOD=basic -# Security Settings -# Set to 'true' to enable DELETE operations (forms and entries) +# ============================================================ +# SECURITY +# ============================================================ +# Set to 'true' to enable DELETE operations (forms, entries, feeds) # WARNING: This allows permanent deletion of data GRAVITY_FORMS_ALLOW_DELETE=false -# Optional: Connection Settings -GRAVITY_FORMS_TIMEOUT=30000 -GRAVITY_FORMS_MAX_RETRIES=3 -GRAVITY_FORMS_RETRY_DELAY=1000 - # SSL Certificate Validation (for local development only) # Set to 'true' to allow self-signed SSL certificates (Laravel Valet, MAMP, Local WP, etc.) -# ⚠️ SECURITY WARNING: Only enable for local development, never in production! -# MCP_ALLOW_SELF_SIGNED_CERTS=true +# SECURITY WARNING: Only enable for local development, never in production! +# GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true + +# ============================================================ +# CONNECTION +# ============================================================ +GRAVITY_FORMS_TIMEOUT=30000 +GRAVITY_FORMS_MAX_RETRIES=3 -# Optional: Debug Settings -# ⚠️ SECURITY WARNING: Debug logs may contain sensitive data (API keys, user info) -# Only enable in secure development environments. Never share debug logs publicly. -# Logs are automatically sanitized but review before sharing with support. +# ============================================================ +# DEBUG +# ============================================================ +# SECURITY WARNING: Debug logs may contain sensitive data (API keys, user info). +# Only enable in secure development environments. GRAVITY_FORMS_DEBUG=false -# Optional: Rate Limiting -GRAVITY_FORMS_RATE_LIMIT=100 -GRAVITY_FORMS_RATE_WINDOW=60000 +# ============================================================ +# TEST ENVIRONMENT +# Use a separate test/staging site to avoid affecting production data. +# ============================================================ -# Test Settings (for integration tests) -# Use a separate test site to avoid affecting production data +# Primary test env var names: GRAVITY_FORMS_TEST_BASE_URL=https://test.yoursite.com GRAVITY_FORMS_TEST_CONSUMER_KEY=ck_test_key_here GRAVITY_FORMS_TEST_CONSUMER_SECRET=cs_test_secret_here -# Enable test mode (MCP-specific setting) +# Additional test overrides (remapped to primary vars in test mode): +# GRAVITY_FORMS_TEST_URL=https://test.yoursite.com (alias for TEST_BASE_URL) +# GRAVITY_FORMS_TEST_AUTH_METHOD=basic +# GRAVITY_FORMS_TEST_TIMEOUT=30000 + +# Shorthand aliases also supported: +# TEST_GF_URL=https://test.yoursite.com +# TEST_GF_CONSUMER_KEY=ck_test_key_here +# TEST_GF_CONSUMER_SECRET=cs_test_secret_here + +# WordPress credentials (used by test scripts, not the MCP server itself): +# TEST_WP_USER=admin +# TEST_WP_PASSWORD=password + +# Enable test mode — when true, GRAVITY_FORMS_TEST_* vars are remapped +# to their primary equivalents so the client connects to the test site. # GRAVITYKIT_MCP_TEST_MODE=true -# Legacy name also supported: GRAVITYMCP_TEST_MODE=true \ No newline at end of file +# Legacy name also supported: GRAVITYMCP_TEST_MODE=true +# Also activated when NODE_ENV=test \ No newline at end of file diff --git a/.mcp.json b/.mcp.json index 2c7ef81..ad04d07 100644 --- a/.mcp.json +++ b/.mcp.json @@ -151,13 +151,13 @@ "optional": [ { "name": "GRAVITY_FORMS_AUTH_METHOD", - "description": "Authentication method: 'basic' (recommended) or 'oauth'", + "description": "Authentication method: 'basic' (recommended) or 'oauth'/'oauth1'. Basic requires HTTPS; falls back to OAuth on HTTP.", "type": "string", "default": "basic" }, { "name": "GRAVITY_FORMS_ALLOW_DELETE", - "description": "Set to 'true' to enable DELETE operations (forms and entries)", + "description": "Set to 'true' to enable DELETE operations (forms, entries, feeds)", "type": "boolean", "default": false }, @@ -175,7 +175,13 @@ }, { "name": "GRAVITY_FORMS_DEBUG", - "description": "Enable debug logging", + "description": "Enable debug logging (output goes to stderr)", + "type": "boolean", + "default": false + }, + { + "name": "GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS", + "description": "Allow self-signed SSL certificates (local development only, never in production)", "type": "boolean", "default": false } @@ -187,7 +193,7 @@ "repository": "https://github.com/GravityKit/MCP", "documentation": "https://github.com/GravityKit/MCP#readme", "api_coverage": "100%", - "total_tools": 24, + "total_tools": 26, "authentication_methods": ["OAuth 1.0a", "Basic Authentication"], "features": [ "Complete REST API v2 coverage", diff --git a/AGENTS.md b/AGENTS.md index c203abd..baf6c8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # AGENTS.md — GravityKit MCP -> MCP server providing 28 tools for full Gravity Forms REST API v2 coverage, enabling AI agents to manage forms, entries, feeds, notifications, and fields programmatically. +> MCP server providing 26 tools for full Gravity Forms REST API v2 coverage, enabling AI agents to manage forms, entries, feeds, notifications, and fields programmatically. ## Quick Start -**What this is:** A Node.js MCP (Model Context Protocol) server that wraps the Gravity Forms REST API v2. It authenticates via Basic Auth (preferred) or OAuth 1.0a and exposes 28 tools for CRUD operations on forms, entries, feeds, notifications, field filters, results, and intelligent field management. +**What this is:** A Node.js MCP (Model Context Protocol) server that wraps the Gravity Forms REST API v2. It authenticates via Basic Auth (preferred) or OAuth 1.0a and exposes 26 tools for CRUD operations on forms, entries, feeds, notifications, field filters, results, and intelligent field management. **Main entry point:** `src/index.js` **Architecture style:** MCP SDK server with stdio transport, single API client, composable validation @@ -134,7 +134,7 @@ Responses are optimized for minimal token usage: | Entries | `gf_list_entries`, `gf_get_entry`, `gf_create_entry`, `gf_update_entry`, `gf_delete_entry` | `listEntries`, `getEntry`, `createEntry`, `updateEntry`, `deleteEntry` | | Submissions | `gf_submit_form_data`, `gf_validate_submission` | `submitFormData`, `validateSubmission` | | Notifications | `gf_send_notifications` | `sendNotifications` | -| Feeds | `gf_list_feeds`, `gf_get_feed`, `gf_list_form_feeds`, `gf_create_feed`, `gf_update_feed`, `gf_patch_feed`, `gf_delete_feed` | `listFeeds`, `getFeed`, `listFormFeeds`, `createFeed`, `updateFeed`, `patchFeed`, `deleteFeed` | +| Feeds | `gf_list_feeds`, `gf_get_feed`, `gf_create_feed`, `gf_update_feed`, `gf_patch_feed`, `gf_delete_feed` | `listFeeds`, `getFeed`, `createFeed`, `updateFeed`, `patchFeed`, `deleteFeed` | | Utilities | `gf_get_field_filters`, `gf_get_results` | `getFieldFilters`, `getResults` | | Field Ops | `gf_add_field`, `gf_update_field`, `gf_delete_field`, `gf_list_field_types` | Handled via `fieldOperationHandlers` → `FieldManager` | @@ -291,6 +291,36 @@ GRAVITY_FORMS_CONSUMER_SECRET=cs_... # Same location GRAVITY_FORMS_BASE_URL=https://... # WordPress site URL (no trailing slash) ``` +Shorthand aliases `GF_CONSUMER_KEY`, `GF_CONSUMER_SECRET`, `GF_URL` are also supported (resolved in `test-config.js`). + +### Optional Environment + +``` +GRAVITY_FORMS_AUTH_METHOD=basic # 'basic' (default) or 'oauth'/'oauth1' +GRAVITY_FORMS_ALLOW_DELETE=false # Must be 'true' to enable delete operations +GRAVITY_FORMS_TIMEOUT=30000 # Request timeout in ms +GRAVITY_FORMS_MAX_RETRIES=3 # Max retry attempts +GRAVITY_FORMS_DEBUG=false # Enable debug logging (stderr) +GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=false # Allow self-signed certs (local dev only) +``` + +**Note:** `GRAVITY_FORMS_RETRY_DELAY`, `GRAVITY_FORMS_RATE_LIMIT`, and `GRAVITY_FORMS_RATE_WINDOW` appear in older docs but are NOT implemented in source code. + +### Test Environment + +``` +GRAVITY_FORMS_TEST_BASE_URL= # Test site URL +GRAVITY_FORMS_TEST_CONSUMER_KEY= # Test site API key +GRAVITY_FORMS_TEST_CONSUMER_SECRET= # Test site API secret +GRAVITY_FORMS_TEST_AUTH_METHOD= # Override auth method for test site +GRAVITY_FORMS_TEST_TIMEOUT= # Override timeout for test site +GRAVITYKIT_MCP_TEST_MODE=true # Enable test mode (remaps TEST_* vars) +``` + +Shorthand aliases: `TEST_GF_URL`, `TEST_GF_CONSUMER_KEY`, `TEST_GF_CONSUMER_SECRET`, `TEST_WP_USER`, `TEST_WP_PASSWORD`. + +Legacy: `GRAVITYMCP_TEST_MODE` and `GRAVITY_FORMS_TEST_URL` are also supported. Test mode also activates when `NODE_ENV=test`. + ### Testing ```bash @@ -328,9 +358,9 @@ No build step — pure ESM JavaScript, runs directly with `node src/index.js`. R 7. **Delete operations are disabled by default.** `GRAVITY_FORMS_ALLOW_DELETE=true` must be explicitly set. Without it, `deleteForm`, `deleteEntry`, and `deleteFeed` throw immediately. This is intentional safety. — `gravity-forms-client.js:88, 292-294` -8. **The `mcp.json` manifest lists 24 tools, but there are actually 28.** The 4 field operation tools (`gf_add_field`, `gf_update_field`, `gf_delete_field`, `gf_list_field_types`) were added after the manifest was written. The `ListToolsRequestSchema` handler in `index.js` is the source of truth. — `mcp.json` vs `src/index.js:517` +8. **The `mcp.json` manifest may be stale.** The `ListToolsRequestSchema` handler in `index.js` plus `fieldOperationTools` in `field-operations/index.js` are the source of truth (22 + 4 = 26 tools total). — `src/index.js` + `src/field-operations/index.js` -9. **Self-signed certs for local dev.** Set `MCP_ALLOW_SELF_SIGNED_CERTS=true` to bypass certificate validation for local WordPress environments (Laravel Valet, Local WP, etc.). Never enable in production. — `gravity-forms-client.js:31-33` +9. **Self-signed certs for local dev.** Set `GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true` to bypass certificate validation for local WordPress environments (Laravel Valet, Local WP, etc.). Never enable in production. — `gravity-forms-client.js:31-33` 10. **Validation has legacy and new patterns.** The validation system has a `BaseValidator` legacy layer wrapping newer `ValidationChain` and domain-specific validators. Both paths are active. New code should use the chain system in `validation-chain.js`. — `config/validation.js:21-260` diff --git a/CLAUDE.md b/CLAUDE.md index 07856ff..a6c33ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ You MUST fully ingest @AGENTS.md first. - **Package:** `@gravitykit/mcp` v2.1.0 - **Type:** Node.js MCP server (ESM) -- **Purpose:** Full Gravity Forms REST API v2 coverage via 28 MCP tools +- **Purpose:** Full Gravity Forms REST API v2 coverage via 26 MCP tools - **Repo:** https://github.com/GravityKit/MCP ## Key Commands diff --git a/README.md b/README.md index 4c714bd..17841b7 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit **For local development** (Laravel Valet, MAMP, etc.): ```env # Add this line if using self-signed certificates - MCP_ALLOW_SELF_SIGNED_CERTS=true + GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true ``` 4. **Generate API credentials** in WordPress: @@ -101,10 +101,9 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit - `gf_submit_form_data` - Submit forms with full processing - `gf_validate_submission` - Validate without submitting -### Add-ons (7 tools) +### Add-ons (6 tools) - `gf_list_feeds` - List all add-on feeds - `gf_get_feed` - Get specific feed configuration -- `gf_list_form_feeds` - List feeds for a specific form - `gf_create_feed` - Create new add-on feeds - `gf_update_feed` - Update existing feeds - `gf_patch_feed` - Partially update feed properties @@ -156,10 +155,18 @@ await mcp.call('gf_submit_form_data', { - `GRAVITY_FORMS_BASE_URL` - WordPress site URL ### Optional Settings +- `GRAVITY_FORMS_AUTH_METHOD=basic` - Auth method: `basic` (recommended) or `oauth`/`oauth1` - `GRAVITY_FORMS_ALLOW_DELETE=false` - Enable delete operations - `GRAVITY_FORMS_TIMEOUT=30000` - Request timeout (ms) +- `GRAVITY_FORMS_MAX_RETRIES=3` - Max retry attempts for failed requests - `GRAVITY_FORMS_DEBUG=false` - Enable debug logging -- `MCP_ALLOW_SELF_SIGNED_CERTS=false` - Allow self-signed SSL certificates (local dev only) +- `GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=false` - Allow self-signed SSL certificates (local dev only) + +### Authentication Flow + +The server uses **Basic Authentication** by default (recommended for Gravity Forms v2). If the site uses HTTP instead of HTTPS, Basic Auth cannot be used and the server **silently falls back to OAuth 1.0a**. Set `GRAVITY_FORMS_AUTH_METHOD=oauth` to force OAuth 1.0a. + +Both methods use the same Consumer Key and Consumer Secret generated in WordPress admin under Forms > Settings > REST API. ## Test Environment Configuration @@ -274,7 +281,7 @@ If you're using a local development environment (Laravel Valet, MAMP, Local WP, Add to your `.env` file: ```env -MCP_ALLOW_SELF_SIGNED_CERTS=true +GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true ``` **⚠️ Security Warning**: Only disable SSL certificate verification for local development environments. Never use this setting in production! @@ -283,7 +290,7 @@ MCP_ALLOW_SELF_SIGNED_CERTS=true - Confirm API keys are correct - Verify user has appropriate Gravity Forms capabilities - Check Forms → Settings → REST API for key status -- For local development, ensure `MCP_ALLOW_SELF_SIGNED_CERTS=true` is set if using self-signed certificates +- For local development, ensure `GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true` is set if using self-signed certificates ### Debug Mode Enable detailed logging: diff --git a/demo-abilities.mjs b/demo-abilities.mjs new file mode 100644 index 0000000..bc7c4d7 --- /dev/null +++ b/demo-abilities.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * Abilities API end-to-end demo. + * + * Walks the new GravityView Abilities surface from cold start to a + * round-trip create + apply + render — same path the MCP and the + * Design Studio React app now use in production. + * + * Run from /Users/zackkatz/Dropbox/MonoKit/MCPs/gravitymcp: + * node /tmp/abilities-demo.mjs + */ + +import 'dotenv/config'; +import { GravityViewClient } from '/Users/zackkatz/Dropbox/MonoKit/MCPs/gravitymcp/src/gravityview-client.js'; +import { loadAbilitiesAsTools, methodForAbility } from '/Users/zackkatz/Dropbox/MonoKit/MCPs/gravitymcp/src/view-operations/abilities-loader.js'; + +const RESET = '\x1b[0m'; +const DIM = '\x1b[2m'; +const CYAN = '\x1b[36m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const MAGENTA = '\x1b[35m'; +const BOLD = '\x1b[1m'; + +function header(s) { + console.log(`\n${BOLD}${CYAN}━━ ${s} ━━${RESET}`); +} +function step(n, s) { + console.log(`\n${BOLD}${MAGENTA}[${n}]${RESET} ${BOLD}${s}${RESET}`); +} +function muted(s) { + console.log(`${DIM}${s}${RESET}`); +} +function value(label, v) { + const json = typeof v === 'string' ? v : JSON.stringify(v, null, 2); + console.log(` ${YELLOW}${label}${RESET}: ${json}`); +} +function ok(s) { + console.log(` ${GREEN}✓${RESET} ${s}`); +} + +const client = new GravityViewClient(process.env); + +// ────────────────────────────────────────────────────────────────── +header('1. Discover the catalog (single network call)'); +// ────────────────────────────────────────────────────────────────── + +step('1a', 'Fetch /wp-json/wp-abilities/v1/abilities'); +const { definitions, handlers, count } = await loadAbilitiesAsTools(client); +ok(`${count} abilities discovered under the gk-gravityview/ namespace`); + +step('1b', 'Categorize them — what can the agent do?'); +const catalogResp = await client.httpClient.request({ + method: 'GET', + baseURL: client.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', +}); +const ours = catalogResp.data.filter(a => a.name?.startsWith('gk-gravityview/')); +const byCat = {}; +for (const a of ours) (byCat[a.category] ||= []).push(a); +for (const cat of Object.keys(byCat).sort()) { + console.log(` ${CYAN}${cat}${RESET} (${byCat[cat].length})`); + byCat[cat].slice(0, 3).forEach(a => muted(` • ${a.name}`)); + if (byCat[cat].length > 3) muted(` … +${byCat[cat].length - 3} more`); +} + +step('1c', 'Show one ability\'s full self-description'); +const sample = ours.find(a => a.name === 'gk-gravityview/list-layouts'); +console.log(` ${BOLD}${sample.name}${RESET}`); +muted(` ${sample.description.slice(0, 140)}…`); +value('annotations', sample.meta?.annotations); +value('output schema', sample.output_schema); + +// ────────────────────────────────────────────────────────────────── +header('2. HTTP method auto-routing (annotations drive the wire)'); +// ────────────────────────────────────────────────────────────────── + +const examples = [ + ours.find(a => a.name === 'gk-gravityview/list-layouts'), // readonly + idempotent → GET + ours.find(a => a.name === 'gk-gravityview/create-view'), // write → POST + ours.find(a => a.name === 'gk-gravityview/remove-view-field'), // destructive + idempotent → DELETE +]; +for (const a of examples) { + const m = methodForAbility(a.meta?.annotations || {}); + const ann = a.meta?.annotations || {}; + console.log(` ${BOLD}${a.name.padEnd(40)}${RESET} → ${GREEN}${m}${RESET} ${DIM}(readonly=${!!ann.readonly} destructive=${!!ann.destructive} idempotent=${!!ann.idempotent})${RESET}`); +} + +// ────────────────────────────────────────────────────────────────── +header('3. Run a readonly ability (zero input)'); +// ────────────────────────────────────────────────────────────────── + +step('3a', 'gv_list_layouts → list installed layout engines'); +const layouts = await handlers.gv_list_layouts({}); +ok(`${layouts.layouts.length} layouts returned`); +layouts.layouts.slice(0, 4).forEach(l => { + console.log(` ${YELLOW}${l.id.padEnd(32)}${RESET} ${l.label}${l.has_grid ? ' ' + GREEN + '[grid]' + RESET : ''}`); +}); + +// ────────────────────────────────────────────────────────────────── +header('4. Run a readonly ability with input (bracketed query params)'); +// ────────────────────────────────────────────────────────────────── + +step('4a', 'gv_get_field_type_schema { field_type: "email" }'); +const emailSchema = await handlers.gv_get_field_type_schema({ field_type: 'email' }); +ok(`${emailSchema.schema.length} settings declared for the email field type`); +emailSchema.schema.slice(0, 5).forEach(s => muted(` • ${s.slug.padEnd(28)} ${s.type.padEnd(12)} ${s.label || ''}`)); + +// ────────────────────────────────────────────────────────────────── +header('5. End-to-end round-trip: create → apply → read'); +// ────────────────────────────────────────────────────────────────── + +step('5a', 'gv_create_view — mint a fresh draft'); +const formId = Number(process.env.GRAVITYVIEW_DEMO_FORM_ID || 296); +const created = await handlers.gv_create_view({ + title: `Abilities API demo · ${new Date().toISOString().slice(11, 19)}`, + form_id: formId, + template_id: 'default_table', + status: 'draft', +}); +ok(`view #${created.view_id} created (version ${created.version})`); +value('admin URL', created.admin_url || `[edit in WP admin via post id ${created.view_id}]`); + +step('5b', 'gv_apply_view_config — add a column with optimistic concurrency'); +const applied = await handlers.gv_apply_view_config({ + id: created.view_id, + fields: { 'directory_table-columns': [{ field_id: '1', slot: 'demo_speaker', custom_label: 'Speaker' }] }, + mode: 'merge', + ifMatch: `"${created.version}"`, +}); +ok(`apply landed → version bumped to ${applied.version}`); +value('applied envelope', applied.applied); + +step('5c', 'gv_get_view_config — read it back'); +const config = await handlers.gv_get_view_config({ id: created.view_id }); +const slot = config.fields['directory_table-columns']?.demo_speaker; +ok(`field present at directory_table-columns.demo_speaker`); +value('stored slot', slot); + +// ────────────────────────────────────────────────────────────────── +header('6. Stale ifMatch → server returns 412 (concurrency in action)'); +// ────────────────────────────────────────────────────────────────── + +step('6a', 'Apply with the OLD version (now stale after 5b)'); +let conflict; +try { + await handlers.gv_apply_view_config({ + id: created.view_id, + fields: { 'directory_table-columns': [{ field_id: '1', slot: 'should_fail', custom_label: 'X' }] }, + mode: 'merge', + ifMatch: `"${created.version}"`, // pre-5b version, deliberately stale + }); +} catch (err) { + conflict = err; +} +if (conflict?.response?.status === 412) { + ok('412 Precondition Failed — server refused the stale write'); + value('error code', conflict.response.data?.code); +} else { + console.log(' (no 412 — race may have served us; concurrency check still firing if you re-run)'); +} + +// ────────────────────────────────────────────────────────────────── +header('7. Direct REST probe (no MCP, no client wrapper)'); +// ────────────────────────────────────────────────────────────────── + +step('7a', 'curl-equivalent GET on a readonly ability'); +const direct = await client.httpClient.request({ + method: 'GET', + baseURL: client.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/list-search-zones/run', +}); +ok(`HTTP ${direct.status} /wp-abilities/v1/abilities/gk-gravityview/list-search-zones/run`); +value('body', direct.data); + +step('7b', 'How an external client (curl, Postman, the React app) calls this'); +console.log(` ${DIM}curl -u user:pass -X POST \\${RESET}`); +console.log(` ${DIM} https://example.com/wp-json/wp-abilities/v1/abilities/gk-gravityview/apply-view-config/run \\${RESET}`); +console.log(` ${DIM} -H 'Content-Type: application/json' \\${RESET}`); +console.log(` ${DIM} -d '{"input":{"id":${created.view_id},"fields":{...}}}'${RESET}`); + +// ────────────────────────────────────────────────────────────────── +console.log(`\n${BOLD}${GREEN}Done.${RESET} View #${created.view_id} left in place for inspection.\n`); diff --git a/mcp.json b/mcp.json index 8c35a4d..e18b812 100644 --- a/mcp.json +++ b/mcp.json @@ -6,7 +6,8 @@ "license": "MIT", "capabilities": { "tools": 26, - "resources": 0 + "resources": 0, + "note": "22 tools in src/index.js + 4 field operation tools in src/field-operations/index.js" }, "tools": [ { @@ -158,13 +159,12 @@ "GRAVITY_FORMS_BASE_URL" ], "optional": [ + "GRAVITY_FORMS_AUTH_METHOD", "GRAVITY_FORMS_ALLOW_DELETE", "GRAVITY_FORMS_TIMEOUT", "GRAVITY_FORMS_MAX_RETRIES", - "GRAVITY_FORMS_RETRY_DELAY", "GRAVITY_FORMS_DEBUG", - "GRAVITY_FORMS_RATE_LIMIT", - "GRAVITY_FORMS_RATE_WINDOW" + "GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS" ] }, "features": { diff --git a/src/gravity-forms-client.js b/src/gravity-forms-client.js index b28ffd7..e2cd585 100644 --- a/src/gravity-forms-client.js +++ b/src/gravity-forms-client.js @@ -29,9 +29,9 @@ export class GravityFormsClient { 'Accept': 'application/json' }, // Allow self-signed certificates for local development - // Set MCP_ALLOW_SELF_SIGNED_CERTS=true in .env for local dev environments + // Set GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true in .env for local dev environments httpsAgent: new https.Agent({ - rejectUnauthorized: config.MCP_ALLOW_SELF_SIGNED_CERTS !== 'true' + rejectUnauthorized: (config.GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS || config.MCP_ALLOW_SELF_SIGNED_CERTS) !== 'true' }) }); diff --git a/src/gravityview-client.js b/src/gravityview-client.js index 44f8a00..f0672d2 100644 --- a/src/gravityview-client.js +++ b/src/gravityview-client.js @@ -39,10 +39,22 @@ export class GravityViewClient { this.baseUrl = baseUrl.replace(/\/$/, ''); this.restNamespace = '/wp-json/gravityview/v1'; - const username = this.config.GRAVITYVIEW_WP_USERNAME || this.config.WP_USERNAME || this.config.GRAVITY_FORMS_CONSUMER_KEY; - const password = this.config.GRAVITYVIEW_WP_APP_PASSWORD || this.config.WP_APP_PASSWORD || this.config.GRAVITY_FORMS_CONSUMER_SECRET; + // Auth resolution order: canonical GRAVITYVIEW_* (prod-style) → + // WORDPRESS_LOCAL_DEV_TEST_* (the local dev.test admin creds; same + // values reused by any other MonoKit tool that hits the local + // install) → generic WP_USERNAME → GF MCP consumer key fallback. + // The descriptive local-dev names exist so this single admin + // credential isn't duplicated across every per-product env block. + const username = this.config.GRAVITYVIEW_WP_USERNAME + || this.config.WORDPRESS_LOCAL_DEV_TEST_ADMIN_USER + || this.config.WP_USERNAME + || this.config.GRAVITY_FORMS_CONSUMER_KEY; + const password = this.config.GRAVITYVIEW_WP_APP_PASSWORD + || this.config.WORDPRESS_LOCAL_DEV_TEST_ADMIN_PASSWORD + || this.config.WP_APP_PASSWORD + || this.config.GRAVITY_FORMS_CONSUMER_SECRET; if (!username || !password) { - throw new Error('GravityView client requires WordPress credentials. Set GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD (or reuse GRAVITY_FORMS_CONSUMER_KEY/SECRET).'); + throw new Error('GravityView client requires WordPress credentials. Set GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD, or WORDPRESS_LOCAL_DEV_TEST_ADMIN_USER + _ADMIN_PASSWORD, or reuse GRAVITY_FORMS_CONSUMER_KEY/SECRET.'); } this.basicAuth = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); @@ -64,24 +76,26 @@ export class GravityViewClient { // callers can opt into automatic If-Match without a manual GET. this.versionCache = new Map(); - this.allowDelete = this.config.GRAVITYVIEW_ALLOW_DELETE === 'true' || this.config.GRAVITY_FORMS_ALLOW_DELETE === 'true'; } resolveBaseUrl() { - return this.config.GRAVITYVIEW_BASE_URL || this.config.GRAVITY_FORMS_BASE_URL || ''; + return this.config.GRAVITYVIEW_BASE_URL + || this.config.WORDPRESS_LOCAL_DEV_TEST_URL + || this.config.GRAVITY_FORMS_BASE_URL + || ''; } /** - * Ping the templates endpoint to verify credentials + connectivity. - * Cheap (no view id required, server returns the registered template - * registry which is small). + * Ping the layouts endpoint to verify credentials + connectivity. + * Cheap (no view id required, server returns the registered layout + * engines which is a short list). */ async testConnection() { try { - const response = await this.httpClient.get('/templates'); + const response = await this.httpClient.get('/layouts'); return { success: true, - templateCount: Array.isArray(response.data?.templates) ? response.data.templates.length : 0, + layoutCount: Array.isArray(response.data?.layouts) ? response.data.layouts.length : 0, baseUrl: `${this.baseUrl}${this.restNamespace}`, }; } catch (error) { @@ -97,8 +111,28 @@ export class GravityViewClient { // Discovery (no view id needed) // =================================================================== - async listTemplates() { - const { data } = await this.httpClient.get('/templates'); + async listLayouts() { + const { data } = await this.httpClient.get('/layouts'); + return data; + } + + /** + * GET /templates/{template_id}/settings-schema — discover every + * setting available for a given template. Returns the same flat + * `[{slug, type, label, value, options, group}, ...]` shape used + * by field-type schemas, so a single client renderer covers both. + * + * Settings from add-ons that bridge their silo meta keys (e.g. + * DataTables under prefix `datatables.*`) appear with dotted + * slugs. Writing a dotted-slug key back through PATCH /apply or + * /template-settings routes the value to the right meta key + * automatically. + */ + async getTemplateSettingsSchema({ template_id } = {}) { + if (!template_id || typeof template_id !== 'string') { + throw new Error('template_id (string) is required.'); + } + const { data } = await this.httpClient.get(`/templates/${encodeURIComponent(template_id)}/settings-schema`); return data; } @@ -122,6 +156,23 @@ export class GravityViewClient { return data; } + /** + * Canonical search-field input slugs. Used by the MCP validator + * (and assertSearchInputType pre-flight) to reject typos. + * + * Now delegates to the gk-gravityview/list-search-input-types + * ability — the legacy `/gravityview/v1/search-fields/input-types` + * route is gone post-Phase-5. + */ + async listSearchFieldInputTypes() { + const { data } = await this.httpClient.request({ + method: 'GET', + baseURL: this.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/list-search-input-types/run', + }); + return data; + } + async listForms() { const { data } = await this.httpClient.get('/forms'); return data; @@ -129,8 +180,19 @@ export class GravityViewClient { async getFieldTypeSchema({ field_type, template_id, context, input_type, form_id } = {}) { if (!field_type) throw new Error('field_type is required.'); - const params = stripUndefined({ template_id, context, input_type, form_id }); - const { data } = await this.httpClient.get(`/field-types/${encodeURIComponent(field_type)}/schema`, { params }); + // Delegate to the gk-gravityview/get-field-type-schema ability. + // Bracketed query params are how WP REST rebuilds an object from + // a query string — `?input[field_type]=text&input[template_id]=...`. + const params = {}; + for (const [k, v] of Object.entries({ field_type, template_id, context, input_type, form_id })) { + if (v !== undefined) params[`input[${k}]`] = v; + } + const { data } = await this.httpClient.request({ + method: 'GET', + baseURL: this.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/get-field-type-schema/run', + params, + }); return data; } @@ -153,7 +215,12 @@ export class GravityViewClient { async listAvailableFields({ id } = {}) { requireViewId(id); - const { data } = await this.httpClient.get(`/views/${id}/available-fields`); + const { data } = await this.httpClient.request({ + method: 'GET', + baseURL: this.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/list-available-fields/run', + params: { 'input[id]': id }, + }); return data; } @@ -172,15 +239,29 @@ export class GravityViewClient { return data; } - async renderViewField({ id, area, slot, settings } = {}) { + async renderViewField({ id, area, slot, settings, staged_slot } = {}) { requireViewId(id); requireAreaSlot(area, slot); - // Server accepts both GET and POST — POST when staged settings - // overrides need to ride along (without persisting). - if (settings && typeof settings === 'object') { + // Server accepts both GET and POST. + // + // POST when the caller needs to ride staged state along WITHOUT + // persisting: + // - `settings` → overrides on an EXISTING saved slot. + // - `staged_slot` → synthesizes a brand-new (unsaved) slot + // from `{ field_id, label?, ...settings }`. + // Required when the URL `slot` doesn't yet + // exist in storage — without it the server + // 404s on read_slot(). + if ( + (settings && typeof settings === 'object') || + (staged_slot && typeof staged_slot === 'object') + ) { + const body = {}; + if (settings && typeof settings === 'object') body.settings = settings; + if (staged_slot && typeof staged_slot === 'object') body.staged_slot = staged_slot; const { data } = await this.httpClient.post( `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}/render`, - { settings } + body ); return data; } @@ -311,10 +392,7 @@ export class GravityViewClient { async removeViewField({ id, area, slot, ifMatch } = {}) { requireViewId(id); requireAreaSlot(area, slot); - if (!this.allowDelete) { - throw new Error('Deletion disabled. Set GRAVITYVIEW_ALLOW_DELETE=true to allow destructive operations.'); - } - const response = await this.httpClient.delete( +const response = await this.httpClient.delete( `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}`, this.ifMatchHeaders(id, ifMatch) ); @@ -371,10 +449,7 @@ export class GravityViewClient { async deleteGridRow({ id, surface, row_uid, ifMatch } = {}) { requireViewId(id); if (!row_uid) throw new Error('row_uid is required.'); - if (!this.allowDelete) { - throw new Error('Deletion disabled. Set GRAVITYVIEW_ALLOW_DELETE=true to allow destructive operations.'); - } - // axios.delete requires `data` inside the config to send a body. +// axios.delete requires `data` inside the config to send a body. const config = this.ifMatchHeaders(id, ifMatch) || {}; if (surface) config.data = { surface }; const response = await this.httpClient.delete( @@ -396,6 +471,7 @@ export class GravityViewClient { if (!field || typeof field !== 'object' || !field.id) { throw new Error('field must be an object with at least an `id` (e.g. "search_all", "submit", or a GF field id).'); } + await this.assertSearchInputType(field.input ?? field.input_type); const response = await this.httpClient.post( `/views/${id}/search-fields/_slots`, stripUndefined({ widget_area, widget_slot, position, field, slot }), @@ -405,12 +481,49 @@ export class GravityViewClient { return response.data; } + /** + * Pre-flight check for `field.input` / `settings.input` values + * passed to addSearchField / patchSearchField. Fetches the + * canonical input-type list once per session and throws a clear + * error before the network round trip when the caller supplies + * an unknown slug. The server enforces the same allow-list as a + * safety net — this just gives the agent a faster, more useful + * error message ("Unknown search input 'datepiker' — did you + * mean 'date_range'?") than a generic 400. + * + * @param {string|undefined} input + * @returns {Promise} + */ + async assertSearchInputType(input) { + const value = String(input ?? '').trim(); + if (value === '') return; // no input → server defaults; nothing to validate + if (!this._searchInputTypes) { + try { + const data = this._searchInputTypesPromise + || (this._searchInputTypesPromise = this.listSearchFieldInputTypes()); + const resolved = await data; + this._searchInputTypes = new Set(Array.isArray(resolved?.input_types) ? resolved.input_types : []); + } catch (_) { + // Discovery failed (older plugin, network blip) — degrade + // to permissive; the server still rejects on write. + this._searchInputTypes = null; + this._searchInputTypesPromise = null; + return; + } + } + if (this._searchInputTypes && this._searchInputTypes.size > 0 && !this._searchInputTypes.has(value)) { + const known = [...this._searchInputTypes].sort().join(', '); + throw new Error(`Unknown search field input "${value}". Valid types: ${known}.`); + } + } + async patchSearchField({ id, widget_area, widget_slot, position, search_slot, settings, ifMatch } = {}) { requireViewId(id); if (!widget_area || !widget_slot) throw new Error('widget_area and widget_slot are required.'); if (!position) throw new Error('position is required.'); if (!search_slot) throw new Error('search_slot is required.'); if (!settings || typeof settings !== 'object') throw new Error('settings must be an object.'); + await this.assertSearchInputType(settings.input ?? settings.input_type); const response = await this.httpClient.patch( `/views/${id}/search-fields/${encodeURIComponent(search_slot)}`, { widget_area, widget_slot, position, settings }, @@ -425,10 +538,7 @@ export class GravityViewClient { if (!widget_area || !widget_slot || !position || !search_slot) { throw new Error('widget_area, widget_slot, position, and search_slot are required.'); } - if (!this.allowDelete) { - throw new Error('Deletion disabled. Set GRAVITYVIEW_ALLOW_DELETE=true to allow destructive operations.'); - } - const config = this.ifMatchHeaders(id, ifMatch) || {}; +const config = this.ifMatchHeaders(id, ifMatch) || {}; config.data = { widget_area, widget_slot, position }; const response = await this.httpClient.delete( `/views/${id}/search-fields/${encodeURIComponent(search_slot)}`, @@ -474,10 +584,7 @@ export class GravityViewClient { async removeViewWidget({ id, area, slot, ifMatch } = {}) { requireViewId(id); requireAreaSlot(area, slot); - if (!this.allowDelete) { - throw new Error('Deletion disabled. Set GRAVITYVIEW_ALLOW_DELETE=true to allow destructive operations.'); - } - const response = await this.httpClient.delete( +const response = await this.httpClient.delete( `/views/${id}/widgets/${encodeURIComponent(area)}/${encodeURIComponent(slot)}`, this.ifMatchHeaders(id, ifMatch) ); @@ -527,13 +634,13 @@ function requireAreaSlot(area, slot) { } function encodeArea(area) { - // Layout-builder areas embed `::` separators that WP REST routes - // tolerate either pre-encoded (%3A%3A) or literal. Use literal to - // keep URLs readable; the server's regex covers both forms. - return String(area) - .split('/') - .map((part) => encodeURIComponent(part).replace(/%3A%3A/g, '::')) - .join('/'); + // Layout-builder areas embed `::` separators (which the server's + // route regex covers either as `::` or `%3A%3A`) AND `/` glyphs + // inside row-type names (`50/50`, `33/33/33`, `25/25/25/25`). The + // `/` MUST be percent-encoded — leaving it literal makes WordPress + // treat it as a path separator and the route stops matching the + // intended segment, fatal-404ing /render and similar endpoints. + return encodeURIComponent(String(area)).replace(/%3A%3A/g, '::'); } function stripUndefined(obj) { diff --git a/src/index.js b/src/index.js index 6bd414c..f36729b 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,7 @@ import { viewToolDefinitions, buildViewToolHandlers, } from './view-operations/index.js'; +import { loadAbilitiesAsTools } from './view-operations/abilities-loader.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -48,7 +49,7 @@ const server = new Server( capabilities: { tools: {} }, - instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields) and gv_* for GravityView Views.\n\nGravityView authoring flow: 1) gv_create_view to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_create_grid_row (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_apply_view_config for bulk one-shot writes, or gv_add_view_field / gv_patch_view_field / gv_move_view_field for surgical edits. 4) For Search Bar internal layout, use gv_add_search_field / gv_patch_search_field / gv_remove_search_field — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_list_templates, gv_list_widgets, gv_list_grid_row_types, gv_list_widget_zones (header/footer), gv_list_search_zones (search-general/search-advanced), gv_list_available_fields. Schema: gv_get_field_type_schema works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_move_view_field accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' + instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields) and gv_* for GravityView Views.\n\nGravityView authoring flow: 1) gv_create_view to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_create_grid_row (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_apply_view_config for bulk one-shot writes, or gv_add_view_field / gv_patch_view_field / gv_move_view_field for surgical edits. 4) For Search Bar internal layout, use gv_add_search_field / gv_patch_search_field / gv_remove_search_field — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_list_layouts (Layout Builder, DIY, Table, List, DataTables, Map — with is_grid_aware flag), gv_list_widgets, gv_list_grid_row_types, gv_list_widget_zones (header/footer), gv_list_search_zones (search-general/search-advanced), gv_list_available_fields. Schema: gv_get_field_type_schema works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_move_view_field accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' } ); @@ -59,6 +60,14 @@ let fieldValidator = null; let gravityViewClient = null; let viewOperations = null; let viewToolHandlers = null; +// Auto-generated from the WordPress Abilities API catalog. Populated +// by initializeClient(). When present, abilityToolDefinitions REPLACES +// the hand-maintained viewToolDefinitions in the tool list, and +// abilityToolHandlers REPLACES the legacy switch for gv_* dispatch. +// When the abilities catalog is unreachable (older WP, plugin off), +// these stay null and the legacy path serves the gv_* tools. +let abilityToolDefinitions = null; +let abilityToolHandlers = null; /** * Initialize Gravity Forms client @@ -94,6 +103,23 @@ async function initializeClient() { viewOperations = createViewOperations(gravityViewClient); viewToolHandlers = buildViewToolHandlers(viewOperations); logger.info('✅ GravityView client initialized — gv_* tools available'); + + // Try to load the WordPress Abilities API catalog. When it + // exists, every `gk-gravityview/*` ability becomes an MCP tool + // (and replaces its hand-maintained gv_* equivalent on the + // wire). When it doesn't exist (older WP, plugin off, network + // blip), the legacy hand-maintained tool defs serve. No-op + // failure — the existing pipeline is the safety net. + try { + const { definitions, handlers, count } = await loadAbilitiesAsTools(gravityViewClient); + abilityToolDefinitions = definitions; + abilityToolHandlers = handlers; + logger.info(`✅ Loaded ${count} GravityView abilities from /wp-abilities/v1 — replacing legacy gv_* defs`); + } catch (abilitiesError) { + logger.warn(`⚠️ Abilities API catalog unavailable: ${abilitiesError.message} — falling back to legacy hand-maintained tool defs`); + abilityToolDefinitions = null; + abilityToolHandlers = null; + } } catch (gvError) { // Don't fail the whole MCP if GravityView credentials are // missing — gf_* tools still work standalone. @@ -101,6 +127,8 @@ async function initializeClient() { gravityViewClient = null; viewOperations = null; viewToolHandlers = null; + abilityToolDefinitions = null; + abilityToolHandlers = null; } return true; @@ -606,8 +634,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { // Field Operations (4 tools) - Intelligent field management ...fieldOperationTools, - // GravityView Inspector (~20 tools) - View configuration CRUD - ...viewToolDefinitions + // GravityView Inspector — auto-generated from the WordPress + // Abilities API catalog when available, falls back to the + // hand-maintained list when not. abilityToolDefinitions is + // populated by initializeClient() above; both arrays are the + // same shape so the spread is uniform. + ...(abilityToolDefinitions ?? viewToolDefinitions) ] }; }); @@ -732,12 +764,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // change in view-operations/index.js. default: if (typeof name === 'string' && name.startsWith('gv_')) { - if (!viewToolHandlers || !gravityViewClient) { + if (!gravityViewClient) { return createErrorResponse( 'GravityView client not initialized. Set GRAVITYVIEW_BASE_URL + GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD in .env (or reuse GRAVITY_FORMS_BASE_URL / GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET when the same WP install hosts both surfaces).' ); } - const handler = viewToolHandlers[name]; + // Prefer the abilities-derived handler when the WordPress + // Abilities API catalog was reachable at startup. Falls back + // to the legacy hand-maintained handler map when not — both + // serve the same gv_* tool name to keep the wire compatible. + const handlerMap = abilityToolHandlers ?? viewToolHandlers; + if (!handlerMap) { + return createErrorResponse( + 'GravityView tool handlers not initialized.' + ); + } + const handler = handlerMap[name]; if (!handler) { return createErrorResponse(`Unknown GravityView tool: ${name}`); } diff --git a/src/tests/views-stress.test.js b/src/tests/views-stress.test.js new file mode 100644 index 0000000..a15828e --- /dev/null +++ b/src/tests/views-stress.test.js @@ -0,0 +1,2710 @@ +/** + * GravityView REST stress tests — portable, runs against any + * GravityView dev install. + * + * Codifies the contracts the round-2 stress sweep proved: + * + * - SHAPE: /layouts uses `has_grid` (not `is_grid_aware`), + * excludes preset_* + *_placeholder; schema responses drop the + * static `groups` map + UI-only keys (priority/class/tooltip/ + * article/codemirror/mount_target/extension/raw-DSL); requires + * envelope merges show/hide sub-keys with single-condition + * collapse + multi-condition QF group wrap; synthetic _id + * hashes stripped while real QF ids preserved; desc HTML + * stripped to plain text; empty values dropped; apply default + * compact; slot_uid alias removed; version string is a real + * timestamp not the Unix epoch. + * - ROUND-TRIP: bulk apply preserves every setting verbatim; + * Unicode + emoji survives custom_label; HTML body of custom + * content survives; conditional_logic v2 doc round-trips; + * move_field keeps slot UID + settings; preview stage transient + * reads back with correct ownership. + * - SANITISATION: text-typed settings strip all HTML; textarea- + * typed settings keep safe HTML via wp_kses_post; Bold link', + }); + const v = stored.custom_label || ''; + TestAssert.isTrue(!v.includes(''), 'b tag stripped'); + TestAssert.isTrue(!v.includes(' { + if (suite.skip) return; + const viewId = await mintView('approval labels strip'); + const stored = await roundTripSlot(viewId, 'directory_list-description', 'sanitappr001', { + field_id: 'is_approved', + approved_label: ' Accepted', + disapproved_label: '✗ Declined', + unapproved_label: '⏳ Pending', + }); + TestAssert.equal(stored.approved_label.trim(), '✓ Accepted'); + TestAssert.isTrue(!stored.disapproved_label.includes('<'), 'no tags'); + TestAssert.isTrue(!stored.unapproved_label.includes('javascript:'), 'javascript: dropped'); + TestAssert.isTrue(stored.unapproved_label.includes('⏳ Pending'), 'Pending text + glyph survive'); +}); + +suite.test('Sanitisation: bare URLs in text settings survive', async () => { + if (suite.skip) return; + const viewId = await mintView('url preservation'); + const stored = await roundTripSlot(viewId, 'directory_list-description', 'sanitrtxt001', { + field_id: 'other_entries', + link_format: 'https://example.com/entries/{entry_id}?ref=view#anchor', + after_link: 'See https://docs.example.com for details.', + }); + TestAssert.equal(stored.link_format, 'https://example.com/entries/{entry_id}?ref=view#anchor'); + TestAssert.isTrue(stored.after_link.includes('https://docs.example.com'), 'URL preserved in after_link'); +}); + +suite.test('Sanitisation: custom content keeps full HTML body', async () => { + if (suite.skip) return; + const viewId = await mintView('custom content html'); + const stored = await roundTripSlot(viewId, 'directory_list-description', 'sanitcust001', { + field_id: 'custom', + content: '

Hello

From here

link
', + wpautop: false, + oembed: false, + }); + TestAssert.isTrue(stored.content.includes('
'), 'div+class survives'); + TestAssert.isTrue(stored.content.includes('

Hello

'), 'h3 survives'); + TestAssert.isTrue(stored.content.includes('href="https://x.example.com"'), 'anchor href survives'); +}); + +suite.test('Sanitisation: numeric values coerce to int regardless of mode', async () => { + if (suite.skip) return; + const viewId = await mintView('numeric coerce'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const stored = await roundTripSlot(viewId, 'directory_table-columns', 'sanitnum001', { + field_id: fieldIds.name, + width: '50', + }); + TestAssert.equal(stored.width, 50, 'string "50" → int 50'); +}); + +// ============================================================ +// VALIDATION: validateAgainstSchemas catches typos +// ============================================================ + +suite.test('Validation: validateAgainstSchemas accepts valid input-type overlay (emailmailto on email field)', async () => { + if (suite.skip) return; + const viewId = await mintView('email overlay valid'); + await validator.validateAgainstSchemas({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.email, + slot: 'validmail001', + emailmailto: '1', + emailsubject:'Hi', + emailbody: 'Re', + }], + }, + }); + // No throw = pass. +}); + +suite.test('Validation: validateAgainstSchemas rejects typo on numeric form field', async () => { + if (suite.skip) return; + const viewId = await mintView('email overlay typo'); + await TestAssert.throwsAsync( + () => validator.validateAgainstSchemas({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.email, + slot: 'invalmail001', + not_a_real_email_setting:'x', + }], + }, + }), + 'unknown setting "not_a_real_email_setting"' + ); +}); + +suite.test('Validation: validateAgainstSchemas rejects typo on meta-field (custom)', async () => { + if (suite.skip) return; + const viewId = await mintView('custom typo'); + await TestAssert.throwsAsync( + () => validator.validateAgainstSchemas({ + id: viewId, + fields: { + 'directory_list-description': [{ + field_id: 'custom', + slot: 'invalcust001', + content: '

OK

', + made_up: 'x', + }], + }, + }), + 'unknown setting "made_up"' + ); +}); + +// ============================================================ +// CONCURRENCY: optimistic 412 on stale ETag +// ============================================================ + +suite.test('Concurrency: parallel writes with same ETag → 1 accepted, rest rejected with 412', async () => { + if (suite.skip) return; + const viewId = await mintView('concurrency lock'); + + // Read once to capture the starting ETag, then fire N parallel + // applies with the same If-Match. Server's GET_LOCK + counter + // bump means exactly ONE should accept. + const config = await h.gv_get_view_config({ id: viewId }); + const etag = `"${config.version}"`; + const N = 5; + + const results = await Promise.allSettled( + Array.from({ length: N }, (_, i) => + h.gv_apply_view_config({ + id: viewId, + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: `conc${String(i).padStart(3, '0')}` }] }, + mode: 'merge', + ifMatch: etag, + }) + ) + ); + + const accepted = results.filter((r) => r.status === 'fulfilled').length; + const rejected = results.filter( + (r) => r.status === 'rejected' && (r.reason?.response?.status === 412 || /412|precondition/i.test(String(r.reason?.message || ''))) + ).length; + + TestAssert.equal(accepted, 1, `expected exactly 1 accepted write, got ${accepted}`); + TestAssert.equal(rejected, N - 1, `expected ${N - 1} stale rejections, got ${rejected}`); +}); + +// ============================================================ +// PREVIEW STAGE: transient round-trip +// ============================================================ + +suite.test('Preview stage: POST returns 32-hex key, DELETE clears, ownership enforced', async () => { + if (suite.skip) return; + const viewId = await mintView('preview stage'); + + const created = await createPreviewStage(viewId, { + fields: { + 'directory_list-title': { + stage1: { id: fieldIds.name, custom_label: 'Staged label' }, + }, + }, + template_settings: { page_size: 99 }, + }); + TestAssert.isTrue( + /^[a-f0-9]{32}$/.test(created.stage_key), + `stage_key matches 32-hex (got "${created.stage_key}")` + ); + + const cleared = await deletePreviewStage(viewId, created.stage_key); + TestAssert.isTrue(cleared.cleared === true || cleared.cleared === undefined, 'DELETE returns OK envelope'); +}); + +// ============================================================ +// WARNINGS: conditional_logic rejections surface in apply response +// ============================================================ + +suite.test('Warnings: valid conditional_logic doc → no warnings in apply response', async () => { + if (suite.skip) return; + const viewId = await mintView('cl valid no warnings'); + const apply = await h.gv_apply_view_config({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.name, + slot: 'clok001', + conditional_logic: { version: 2, actionType: 'show', logicType: 'all', rules: [] }, + }], + }, + mode: 'merge', + }); + TestAssert.isTrue( + !('warnings' in apply) || apply.warnings.length === 0, + 'valid CL emits no warnings' + ); +}); + +suite.test('Warnings: CL missing version → reason=missing_version, value dropped', async () => { + if (suite.skip) return; + const viewId = await mintView('cl missing version'); + const apply = await h.gv_apply_view_config({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.name, + slot: 'clbad001', + // Missing `version` key — the advanced-filter reader's v1-upgrade + // path would crash on this in the public View render. + conditional_logic: { actionType: 'show', logicType: 'all', rules: [{ fieldId: fieldIds.name, operator: 'is', value: 'X' }] }, + }], + }, + mode: 'merge', + }); + TestAssert.isTrue(Array.isArray(apply.warnings), 'warnings array present on rejection'); + const match = apply.warnings.find((w) => + w.key === 'conditional_logic' && + w.slot === 'clbad001' && + w.area === 'directory_list-title' && + w.reason === 'missing_version' + ); + TestAssert.isNotNull(match, 'warning carries area/slot/key/reason=missing_version'); + + // Confirm the value was actually dropped from the persisted slot. + const config = await h.gv_get_view_config({ id: viewId }); + const stored = config.fields['directory_list-title']['clbad001']; + TestAssert.isTrue( + !stored.conditional_logic || stored.conditional_logic === '', + 'rejected CL was dropped (empty string or absent)' + ); +}); + +// ============================================================ +// WIDGET CREATE: persists ALL payload settings, not just id+label +// ============================================================ + +suite.test('Widget create: persists every payload setting beyond id+label', async () => { + if (suite.skip) return; + const viewId = await mintView('widget create persists settings'); + + // default_table has stable static widget zones (header_top / + // footer_top) — using it instead of Layout Builder lets the + // test target a known area without first creating grid rows. + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + + const created = await h.gv_add_view_widget({ + id: viewId, + area: 'header_top', + widget: { + field_id: 'custom_content', + label: 'Headline', + content: '

Welcome to our list.

', + wpautop: false, + custom_class: 'top-banner highlight', + }, + }); + + // Confirm the response echoes the persisted slot (not just id+label). + TestAssert.isTrue(created.values?.content?.includes('our list'), 'content survives in response echo'); + TestAssert.equal(created.values.custom_class, 'top-banner highlight'); + + // Confirm GET /config sees the same settings (proves persistence, + // not just response shape). + const config = await h.gv_get_view_config({ id: viewId }); + const slot = config.widgets?.header_top?.[created.slot]; + TestAssert.isNotNull(slot, 'widget slot persisted in config'); + TestAssert.equal(slot.id, 'custom_content'); + TestAssert.equal(slot.label, 'Headline'); + TestAssert.isTrue(slot.content.includes('our list'), 'content survives in persisted config'); + TestAssert.equal(slot.custom_class, 'top-banner highlight'); +}); + +suite.test('Widget create: search_bar payload auto-migrates to modern shape', async () => { + if (suite.skip) return; + const viewId = await mintView('widget create search_bar modern'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + + const created = await h.gv_add_view_widget({ + id: viewId, + area: 'header_top', + widget: { + field_id: 'search_bar', + label: 'Find', + search_layout: 'horizontal', + search_clear: '1', + }, + }); + + TestAssert.equal(created.values.search_layout, 'horizontal'); + // Server coerces numeric strings to int via sanitize_setting_value. + TestAssert.equal(Number(created.values.search_clear), 1); + // Modern shape lives under `search_fields_section`; legacy + // `search_fields` JSON should NOT be persisted by a fresh write. + TestAssert.isTrue( + !('search_fields' in created.values) || created.values.search_fields === '', + 'no legacy search_fields JSON on fresh write' + ); +}); + +// ============================================================ +// RENDER: staged_slot lets unsaved slots preview +// ============================================================ + +suite.test('Render: unknown slot WITHOUT staged_slot returns 404', async () => { + if (suite.skip) return; + const viewId = await mintView('render no staged'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + + let status = null; + try { + await h.gv_render_view_field({ + id: viewId, + area: 'directory_table-columns', + slot: 'never0001', + }); + } catch (err) { + status = err?.response?.status ?? null; + } + TestAssert.equal(status, 404, 'unknown slot 404s when no staged_slot supplied'); +}); + +suite.test('Render: staged_slot synthesizes an unsaved slot for preview', async () => { + if (suite.skip) return; + const viewId = await mintView('render staged unsaved'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + + // Render with `staged_slot` carrying field_id + a custom_label — + // server should synthesize a slot record and run the production + // renderer instead of returning 404. Result envelope shape comes + // from the existing /render endpoint contract. + let result; + try { + result = await h.gv_render_view_field({ + id: viewId, + area: 'directory_table-columns', + slot: 'staged0001', + staged_slot: { + field_id: fieldIds.name, + custom_label: 'Preview-only label', + show_label: '1', + }, + }); + } catch (err) { + // 503 "no entry" is acceptable when the form has zero entries + // — that's a renderer limitation, not a staged_slot one. Skip + // gracefully in that case. + if (err?.response?.status === 503) { + console.log(' (skipping body assertions — form has no entries to render against)'); + return; + } + throw err; + } + + TestAssert.isNotNull(result, 'render returns a body'); + // The render response carries `html` (the rendered slot markup). + // Confirm the slot UID we passed in surfaces in the data-gv-slot + // attribute so the LivePreview receiver can match the staged + // node to the slot. + const html = String(result.html ?? ''); + TestAssert.isTrue(html.length > 0, 'rendered HTML body is non-empty'); +}); + +suite.test('Render: settings override on saved slot still works (regression)', async () => { + if (suite.skip) return; + const viewId = await mintView('render settings override'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + + // First save a real slot. + await h.gv_apply_view_config({ + id: viewId, + fields: { + 'directory_table-columns': [{ field_id: fieldIds.name, slot: 'saved001' }], + }, + mode: 'merge', + }); + + // Then render with a settings override. Should not 404. + let status = null; + try { + await h.gv_render_view_field({ + id: viewId, + area: 'directory_table-columns', + slot: 'saved001', + settings: { custom_label: 'Overridden via render', show_label: '1' }, + }); + } catch (err) { + // 503 = no entry to render against → still proves the slot + // was found (no 404). Anything other than 404/503 is bad. + status = err?.response?.status ?? null; + if (status !== 503) throw err; + } + TestAssert.isTrue(status === null || status === 503, 'saved slot found (no 404 regression)'); +}); + +// ============================================================ +// SEARCH FIELD INPUT TYPES: server discovery + write-time reject +// ============================================================ + +suite.test('Search input types: GET /search-fields/input-types returns canonical core slugs', async () => { + if (suite.skip) return; + const { input_types } = await h.gv_list_search_input_types({}); + TestAssert.isTrue(Array.isArray(input_types), 'input_types is an array'); + // The core list must contain at minimum these slugs. Add-ons may + // contribute more via the gravityview/search/input_labels filter, + // so we don't pin an exact length. + for (const required of ['input_text', 'select', 'date', 'date_range', 'submit', 'hidden']) { + TestAssert.isTrue(input_types.includes(required), `core slug "${required}" present`); + } +}); + +suite.test('Search input types: client pre-flight throws on typo BEFORE network call', async () => { + if (suite.skip) return; + await TestAssert.throwsAsync( + () => gvClient.assertSearchInputType('datepiker'), + 'Unknown search field input "datepiker"' + ); +}); + +suite.test('Search input types: client pre-flight accepts valid slug', async () => { + if (suite.skip) return; + await gvClient.assertSearchInputType('date_range'); + await gvClient.assertSearchInputType('input_text'); + // Empty / undefined → no-op (server defaults). + await gvClient.assertSearchInputType(''); + await gvClient.assertSearchInputType(undefined); +}); + +suite.test('Search input types: server rejects typo with 400 + helpful error', async () => { + if (suite.skip) return; + const viewId = await mintView('search input server reject'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + + // Server-side allow-list check. The ability shim threads the input + // through to the legacy create_search_field_slot validator, which + // rejects unknown input slugs with a 400 — error surfaces back as + // an axios throw with err.response.data.message. + let serverStatus = null; + let serverMessage = ''; + try { + await h.gv_add_search_field({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::ROW_STUB', + field: { id: fieldIds.name, input: 'datepiker', label: 'Bad' }, + }); + } catch (err) { + serverStatus = err?.response?.status ?? null; + serverMessage = String(err?.response?.data?.message ?? err?.message ?? ''); + } + TestAssert.equal(serverStatus, 400, 'server returns 400'); + TestAssert.isTrue( + /not allowed for field|Unknown search field input/.test(serverMessage), + `server rejection message present (got "${serverMessage}")` + ); + TestAssert.isTrue(serverMessage.includes('datepiker'), 'server echoes the bad slug'); + TestAssert.isTrue(serverMessage.includes('input_text'), 'server lists a known-valid slug'); +}); + +// ============================================================ +// TEMPLATE SETTINGS SOURCES (unified discovery + bridge) +// +// These tests exercise the inspector's template-settings architecture +// using a TEST mu-plugin fixture that registers two FAKE silo'd +// sources via `gk/gravityview/rest/template-settings/sources`. The +// fixture lives at GravityView/tests/fixtures/inspector-template-sources-mock.php +// and is auto-installed in beforeAll when GRAVITYVIEW_PLUGIN_PATH + +// WP_MU_PLUGINS_DIR env vars point at the dev install. Without +// either env var set, the mock-source tests skip (the contract is +// still partially exercised by the always-present core-source test). +// +// Fixture sources: +// - prefix `mockone` on template `default_table`, schema { foo, bar, content } +// - prefix `mocktwo` on template `default_list`, schema { alpha, beta } +// ============================================================ + +suite.test('Template settings schema: core source always exposes shared keys (page_size etc.)', async () => { + if (suite.skip) return; + const { schema, template_id } = await h.gv_get_template_settings_schema({ template_id: 'default_table' }); + TestAssert.equal(template_id, 'default_table'); + TestAssert.isTrue(Array.isArray(schema) && schema.length > 0, 'schema array non-empty'); + const slugs = new Set(schema.map((it) => it?.slug)); + TestAssert.isTrue(slugs.has('page_size'), 'core slug "page_size" present'); +}); + +suite.test('Mock source: schema exposes dotted slugs gated by template_ids', async () => { + if (suite.skip || !mockFixtureActive) { + console.log(' (skipping — mock fixture not installed; set GRAVITYVIEW_PLUGIN_PATH + WP_MU_PLUGINS_DIR)'); + return; + } + // mockone is gated on default_table — should appear there + nowhere else. + const onTable = await h.gv_get_template_settings_schema({ template_id: 'default_table' }); + const onList = await h.gv_get_template_settings_schema({ template_id: 'default_list' }); + + const tableSlugs = new Set(onTable.schema.map((it) => it?.slug)); + const listSlugs = new Set(onList.schema.map((it) => it?.slug)); + + for (const required of ['mockone.foo', 'mockone.bar', 'mockone.content']) { + TestAssert.isTrue(tableSlugs.has(required), `mockone slug "${required}" present on default_table`); + TestAssert.isTrue(!listSlugs.has(required), `mockone slug "${required}" NOT present on default_list (template_ids gate)`); + } + for (const required of ['mocktwo.alpha', 'mocktwo.beta']) { + TestAssert.isTrue(listSlugs.has(required), `mocktwo slug "${required}" present on default_list`); + TestAssert.isTrue(!tableSlugs.has(required), `mocktwo slug "${required}" NOT present on default_table`); + } +}); + +suite.test('Mock source: core source dedupes entries claimed by silo `groups`', async () => { + if (suite.skip) return; + if (!mockFixtureActive) { + console.log(' (skipping — mock fixture not installed; set GRAVITYVIEW_PLUGIN_PATH + WP_MU_PLUGINS_DIR)'); + return; + } + // The mock sources own `mock_silo` + `mock_alt` groups. Even + // though View_Settings::defaults(true) doesn't naturally surface + // those slugs, the dedupe logic must still ensure the core source + // emits NOTHING with those group values regardless of how the + // catalog grows. Belt-and-braces check. + const { schema } = await h.gv_get_template_settings_schema({ template_id: 'default_table' }); + for (const it of schema) { + if ((it?.group ?? '') === 'mock_silo' && !(it?.slug ?? '').startsWith('mockone.')) { + throw new Error(`core source emitted undotted slug "${it.slug}" for silo'd group "mock_silo"`); + } + } +}); + +suite.test('Mock source: PATCH /template-settings routes nested writes to the right silo meta', async () => { + if (suite.skip) return; + if (!mockFixtureActive) { + console.log(' (skipping — mock fixture not installed; set GRAVITYVIEW_PLUGIN_PATH + WP_MU_PLUGINS_DIR)'); + return; + } + const viewId = await mintView('mock silo round-trip'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + + await h.gv_patch_view_settings({ + id: viewId, + template_settings: { + mockone: { foo: 'hello', bar: '42', content: '

html ok

' }, + page_size: '99', // top-level — must stay on core meta + }, + }); + + const config = await h.gv_get_view_config({ id: viewId }); + TestAssert.equal(String(config.template_settings.page_size), '99', 'top-level page_size on core meta'); + TestAssert.isTrue( + config.template_settings.mockone && typeof config.template_settings.mockone === 'object', + 'mockone namespace present in read' + ); + TestAssert.equal(config.template_settings.mockone.foo, 'hello'); + TestAssert.equal(Number(config.template_settings.mockone.bar), 42, 'numeric coercion through sanitize_setting_value'); + TestAssert.isTrue( + String(config.template_settings.mockone.content).includes('html ok'), + 'textarea-typed setting content survives' + ); +}); + +suite.test('Mock source: /apply also splits namespaced writes to silo meta', async () => { + if (suite.skip) return; + if (!mockFixtureActive) { + console.log(' (skipping — mock fixture not installed; set GRAVITYVIEW_PLUGIN_PATH + WP_MU_PLUGINS_DIR)'); + return; + } + const viewId = await mintView('mock silo apply path'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + + await h.gv_apply_view_config({ + id: viewId, + template_settings: { + page_size: '25', + mockone: { foo: 'via-apply' }, + }, + mode: 'merge', + }); + + const config = await h.gv_get_view_config({ id: viewId }); + TestAssert.equal(String(config.template_settings.page_size), '25', 'core key persisted via apply'); + TestAssert.equal(config.template_settings.mockone?.foo, 'via-apply', 'silo key persisted via apply'); +}); + +suite.test('Mock source: keys NOT in the partial payload survive the merge', async () => { + if (suite.skip) return; + if (!mockFixtureActive) { + console.log(' (skipping — mock fixture not installed; set GRAVITYVIEW_PLUGIN_PATH + WP_MU_PLUGINS_DIR)'); + return; + } + const viewId = await mintView('mock silo non-overlap merge'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + + // Seed two keys on the silo, then patch only one — the other + // must remain. This proves the per-source bucket-seeded merge + // doesn't blow away unmentioned silo keys. + await h.gv_patch_view_settings({ + id: viewId, + template_settings: { mockone: { foo: 'first', bar: '7' } }, + }); + await h.gv_patch_view_settings({ + id: viewId, + template_settings: { mockone: { foo: 'second' } }, + }); + + const config = await h.gv_get_view_config({ id: viewId }); + TestAssert.equal(config.template_settings.mockone?.foo, 'second', 'updated key takes new value'); + TestAssert.equal(Number(config.template_settings.mockone?.bar), 7, 'untouched key survives merge'); +}); + +// ============================================================ +// VALIDATION + SANITIZATION REGRESSIONS +// ============================================================ + +suite.test('CL with leading whitespace is accepted (trim bug)', async () => { + if (suite.skip) return; + const viewId = await mintView('cl trim bug'); + const padded = ' ' + JSON.stringify({ version: 2, actionType: 'show', logicType: 'all', rules: [] }) + ' \n'; + const apply = await h.gv_apply_view_config({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.name, + slot: 'cltrim001', + conditional_logic: padded, + }], + }, + mode: 'merge', + }); + TestAssert.isTrue( + !('warnings' in apply) || apply.warnings.length === 0, + 'no warning emitted — padded JSON was trimmed and accepted' + ); + + const config = await h.gv_get_view_config({ id: viewId }); + const stored = config.fields['directory_list-title']['cltrim001'].conditional_logic; + TestAssert.isTrue( + typeof stored === 'string' && stored.startsWith('{') && stored.endsWith('}'), + `stored CL is the canonical trimmed JSON (got "${stored}")` + ); +}); + +suite.test('Per-field narrowing rejects date_range on search_mode', async () => { + if (suite.skip) return; + const viewId = await mintView('per-field narrow search_mode'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + + let serverStatus = null; + let serverMessage = ''; + try { + await h.gv_add_search_field({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::ROW_STUB', + // `date_range` IS in the global allow-list, but invalid for + // `search_mode` per get_input_types_by_field_type. Narrow check + // must reject this combination. + field: { id: 'search_mode', input: 'date_range', label: 'Bad combo' }, + }); + } catch (err) { + serverStatus = err?.response?.status ?? null; + serverMessage = String(err?.response?.data?.message ?? err?.message ?? ''); + } + TestAssert.equal(serverStatus, 400, 'server rejects field-invalid combination'); + TestAssert.isTrue(serverMessage.includes('search_mode'), 'error names the field id'); + TestAssert.isTrue(serverMessage.includes('date_range'), 'error names the bad input'); +}); + +suite.test('Per-field narrowing accepts hidden on search_mode', async () => { + if (suite.skip) return; + const viewId = await mintView('per-field narrow search_mode ok'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + + // `hidden` IS valid for search_mode — should succeed. + const created = await h.gv_add_search_field({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::ROW_OK', + field: { id: 'search_mode', input: 'hidden', label: 'Mode' }, + }); + TestAssert.isNotNull(created?.search_slot); +}); + +suite.test('create_widget_slot rejects nested invalid search_fields_section', async () => { + if (suite.skip) return; + const viewId = await mintView('widget nested search reject'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + + let serverStatus = null; + let serverMessage = ''; + try { + await h.gv_add_view_widget({ + id: viewId, + area: 'header_top', + widget: { + field_id: 'search_bar', + label: 'Search', + search_fields_section: { + 'search-general_top::100::ROW': { + stub1: { id: 'search_mode', input: 'date_range', label: 'Bad nested' }, + }, + }, + }, + }); + } catch (err) { + serverStatus = err?.response?.status ?? null; + serverMessage = String(err?.response?.data?.message ?? err?.message ?? ''); + } + TestAssert.equal(serverStatus, 400, 'server rejects nested invalid search input'); + TestAssert.isTrue(serverMessage.includes('Nested search field'), 'error identifies the nested entry'); + TestAssert.isTrue(serverMessage.includes('search_mode'), 'error names the offending field id'); +}); + +suite.test('Warnings: CL string that isn\'t a JSON object → reason=not_json_object', async () => { + if (suite.skip) return; + const viewId = await mintView('cl not json object'); + const apply = await h.gv_apply_view_config({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.name, + slot: 'clbad002', + // String that's neither empty nor a JSON object. + conditional_logic: 'hello world', + }], + }, + mode: 'merge', + }); + TestAssert.isTrue(Array.isArray(apply.warnings), 'warnings array present'); + const match = apply.warnings.find((w) => + w.slot === 'clbad002' && w.reason === 'not_json_object' + ); + TestAssert.isNotNull(match, 'warning carries reason=not_json_object'); +}); + +// ============================================================ +// BUGS-CAUGHT regressions — each test pins behaviour that a real +// user demo just surfaced. If any of these fail, the corresponding +// bug has come back. +// ============================================================ + +suite.test('Area keys: gv_create_grid_row returns ready-to-use prefixed area_keys', async () => { + if (suite.skip) return; + const viewId = await mintView('area_keys contract'); + // Layout Builder is the only grid-aware template by default. + await h.gv_patch_view_template({ id: viewId, template_id: 'gravityview-layout-builder' }); + const row = await h.gv_add_grid_row({ + id: viewId, + type: '25/25/25/25', + zones: ['directory'], + }); + TestAssert.isTrue(Array.isArray(row.area_keys), '`area_keys` is an array'); + TestAssert.equal(row.area_keys.length, 4, '4 cells from a 25/25/25/25 row'); + // Every entry must already carry the zone prefix. + row.area_keys.forEach((k) => { + TestAssert.isTrue( + k.startsWith('directory_'), + `area_key "${k}" carries the directory_ prefix` + ); + }); +}); + +suite.test('Area keys: apply_view_config REJECTS a fields area key missing the zone prefix', async () => { + if (suite.skip) return; + const viewId = await mintView('reject unprefixed'); + await h.gv_patch_view_template({ id: viewId, template_id: 'gravityview-layout-builder' }); + const row = await h.gv_add_grid_row({ id: viewId, type: '100', zones: ['directory'] }); + // Use the LEGACY unprefixed key (this is the exact bug the demo hit). + const badKey = `gravityview-layout-builder-top::100::${row.row_uid}`; + let status = null, code = null; + try { + await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + fields: { [badKey]: [{ field_id: fieldIds.name, slot: 'should_fail' }] }, + }); + } catch (err) { + status = err?.response?.status ?? null; + code = err?.response?.data?.code ?? null; + } + TestAssert.equal(status, 400, 'unprefixed area key → 400'); + TestAssert.equal(code, 'gv_rest_invalid_area_key', 'specific error code surfaces'); +}); + +suite.test('Area keys: apply_view_config REJECTS a bogus widget area key', async () => { + if (suite.skip) return; + const viewId = await mintView('reject bogus widget area'); + let status = null, code = null; + try { + await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + widgets: { 'not_a_real_zone': [{ field_id: 'search_bar', slot: 'x' }] }, + }); + } catch (err) { + status = err?.response?.status ?? null; + code = err?.response?.data?.code ?? null; + } + TestAssert.equal(status, 400, 'bogus widget zone → 400'); + TestAssert.equal(code, 'gv_rest_invalid_area_key', 'specific error code surfaces'); +}); + +suite.test('Area keys: prefixed keys round-trip end-to-end (create-row → use area_keys → read-back)', async () => { + if (suite.skip) return; + const viewId = await mintView('e2e prefixed roundtrip'); + await h.gv_patch_view_template({ id: viewId, template_id: 'gravityview-layout-builder' }); + const row = await h.gv_add_grid_row({ id: viewId, type: '50/50', zones: ['directory'] }); + TestAssert.equal(row.area_keys.length, 2); + + // Use the API's own returned keys verbatim — the test the demo would have passed. + await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + fields: { + [row.area_keys[0]]: [{ field_id: fieldIds.name, slot: 'rt_a' }], + [row.area_keys[1]]: [{ field_id: fieldIds.email, slot: 'rt_b' }], + }, + }); + + const cfg = await h.gv_get_view_config({ id: viewId }); + TestAssert.isNotNull(cfg.fields[row.area_keys[0]]?.rt_a, 'first area has its slot after apply'); + TestAssert.isNotNull(cfg.fields[row.area_keys[1]]?.rt_b, 'second area has its slot after apply'); + + // No orphan unprefixed keys. + const orphans = Object.keys(cfg.fields || {}).filter( + (k) => !k.startsWith('directory_') && !k.startsWith('single_') && !k.startsWith('edit_'), + ); + TestAssert.equal(orphans.length, 0, `no orphan unprefixed area keys (got: ${orphans.join(', ') || 'none'})`); +}); + +suite.test('Inspector shape: template_ids contains directory + single ONLY (no edit)', async () => { + if (suite.skip) return; + const viewId = await mintView('template_ids no edit'); + const cfg = await h.gv_get_view_config({ id: viewId }); + TestAssert.isNotNull(cfg.template_ids?.directory, 'template_ids.directory present'); + TestAssert.isNotNull(cfg.template_ids?.single, 'template_ids.single present'); + TestAssert.isTrue( + !('edit' in (cfg.template_ids || {})), + 'template_ids.edit absent — Edit Entry has no per-zone template choice', + ); +}); + +suite.test('Inspector shape: template_settings does NOT carry the legacy `template` key', async () => { + if (suite.skip) return; + const viewId = await mintView('no legacy template key'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const cfg = await h.gv_get_view_config({ id: viewId }); + TestAssert.isTrue( + !('template' in (cfg.template_settings || {})), + 'template_settings.template stripped — canonical store is template_ids.directory', + ); +}); + +suite.test('Inspector shape: template_settings stays clean even after a template switch', async () => { + if (suite.skip) return; + const viewId = await mintView('template switch no leak'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_patch_view_template({ id: viewId, template_id: 'gravityview-layout-builder' }); + const cfg = await h.gv_get_view_config({ id: viewId }); + TestAssert.isTrue( + !('template' in (cfg.template_settings || {})), + 'template_settings.template still absent after switch', + ); +}); + +// ============================================================ +// Search-bar legacy-save round-trip +// +// The bug: MCP-written search fields used `input` + `type` keys +// and bare numeric GF ids. The legacy admin metabox parser only +// recognises `input_type` and `{form_id}::{field_id}` ids. On +// save through the legacy UI, every MCP-written field was +// silently dropped. +// +// The fix: normalise_search_field_payload now routes every entry +// through Search_Field::from_configuration() → to_configuration(), +// so storage carries the canonical shape regardless of who wrote it. +// ============================================================ + +suite.test('Search field shape: gv_add_search_field emits the domain canonical shape', async () => { + if (suite.skip) return; + const viewId = await mintView('search field canonical shape'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + const created = await h.gv_add_search_field({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::canonshape_row', + field: { id: fieldIds.name, input: 'input_text', label: 'Speaker' }, + }); + + const cfg = await h.gv_get_view_config({ id: viewId }); + const stored = cfg.widgets.header_top[widget.slot].search_fields_section[ + 'search-general_top::100::canonshape_row' + ][created.search_slot]; + + // Canonical shape per Search_Field::to_configuration(): + // id, UID, type ({form_id}::{field_id}), label, position, form_id, + // show_label, input_type, plus any explicit settings. + // For GF fields the GF subclass also emits `form_field` (the resolved + // GF Field object). + TestAssert.equal(stored.input_type, 'input_text', 'input_type set (canonical)'); + TestAssert.isTrue(!('input' in stored), 'input alias dropped after translation'); + TestAssert.isTrue('UID' in stored, 'UID minted by domain'); + TestAssert.isTrue('type' in stored, 'type emitted by domain (canonical {form_id}::{field_id})'); + TestAssert.isTrue('form_id' in stored, 'form_id stamped per field'); + TestAssert.isTrue('show_label' in stored, 'show_label default carried (true unless overridden)'); + // The legacy-admin pad-with-empties keys are intentionally absent — + // the domain class only emits what was actually set, and downstream + // consumers (required_cap, etc.) read with `?? defaults`. + TestAssert.isTrue(!('custom_label' in stored), 'custom_label NOT padded (domain emits only set settings)'); + TestAssert.isTrue(!('only_loggedin' in stored), 'only_loggedin NOT padded (default is null at read time)'); +}); + +suite.test('Search field shape: GF field carries `type`={form_id}::{field_id} + form_field object', async () => { + if (suite.skip) return; + const viewId = await mintView('search field gf canonical type'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + await h.gv_add_search_field({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::gftype_row', + field: { id: fieldIds.email, input: 'input_text', label: 'Email' }, + }); + + const cfg = await h.gv_get_view_config({ id: viewId }); + const slot = Object.values( + cfg.widgets.header_top[widget.slot].search_fields_section['search-general_top::100::gftype_row'], + )[0]; + + // The domain emits the canonical `{form_id}::{field_id}` under the + // `type` key (Search_Field_Gravity_Forms::get_type), keeping `id` + // as the bare GF id the user supplied. + TestAssert.isTrue( + /^\d+::\d+(\.\d+)?$/.test(String(slot.type)), + `type is form-prefixed canonical id (got "${slot.type}")`, + ); + // GF subclass also resolves the inner GF field array into form_field. + TestAssert.isTrue(slot.form_field && typeof slot.form_field === 'object', 'form_field object resolved'); + TestAssert.isTrue('id' in slot.form_field && 'type' in slot.form_field, 'form_field carries the GF field shape'); +}); + +suite.test('Search field shape: bulk apply (gv_apply_view_config) routes nested entries through the domain too', async () => { + if (suite.skip) return; + const viewId = await mintView('bulk normalise search section'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + // Bulk-apply a nested search_fields_section through gv_apply_view_config — + // entries SHOULD pass through Search_Field::from_configuration → to_configuration + // just like the per-field CRUD path does. + await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + widgets: { + header_top: [{ + field_id: 'search_bar', + slot: widget.slot, + label: 'Search', + search_fields_section: { + 'search-general_top::100::bulkrow': { + bulkfield: { id: fieldIds.name, input: 'input_text', label: 'Bulk-name' }, + }, + }, + }], + }, + }); + + const cfg = await h.gv_get_view_config({ id: viewId }); + const stored = cfg.widgets.header_top[widget.slot].search_fields_section[ + 'search-general_top::100::bulkrow' + ].bulkfield; + TestAssert.equal(stored.input_type, 'input_text', 'input_type translated in bulk path'); + TestAssert.isTrue(!('input' in stored), 'input alias dropped in bulk path'); + TestAssert.isTrue('UID' in stored, 'UID minted in bulk path'); + TestAssert.isTrue('form_id' in stored, 'form_id stamped in bulk path'); + TestAssert.isTrue('show_label' in stored, 'show_label default in bulk path'); + TestAssert.isTrue( + /^\d+::\d+(\.\d+)?$/.test(String(stored.type)), + `type is form-prefixed in bulk path (got "${stored.type}")`, + ); + TestAssert.isTrue(stored.form_field && typeof stored.form_field === 'object', 'form_field object resolved in bulk path'); +}); + +suite.test('Search field round-trip: fields survive a legacy WP save_post (bug-caught regression)', async () => { + if (suite.skip) return; + // Canonical bug-caught test: MCP writes a search bar; we re-trigger + // save_post (the same chain the legacy admin metabox save fires); + // MCP-written fields MUST still be there afterwards. Without + // Search_Field domain delegation, the legacy parser would drop + // every field with non-canonical keys. + // + // Uses wp-cli (via the cp.execSync helper). When wp-cli isn't + // reachable from the test runner (e.g., remote CI), skips with a + // clear note. + // Requires both wp-cli on PATH AND a WP_ROOT env var pointing at + // the WordPress install. Skips cleanly without a hardcoded fallback + // — this test runs in any dev env that supplies them. + const wpRoot = process.env.WP_ROOT; + if (!wpRoot) { + console.log(' (skipping — set WP_ROOT to your WP install path to run this test)'); + return; + } + let cp; + try { + cp = await import('node:child_process'); + cp.execSync('which wp', { stdio: 'pipe' }); + } catch (_) { + console.log(' (skipping — wp-cli not available; install wp-cli to run this test)'); + return; + } + + const viewId = await mintView('search field legacy roundtrip'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + await h.gv_add_search_field({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::roundtrip_row', + slot: 'roundtrip_slot', + field: { id: fieldIds.name, input: 'input_text', label: 'Speaker' }, + }); + + // Trigger save_post via wp-cli — runs the legacy metabox save_post + // hook chain end-to-end against the live WP install. + cp.execSync(`cd ${wpRoot} && wp post update ${viewId} --post_modified="$(date -u +'%Y-%m-%d %H:%M:%S')"`, { stdio: 'pipe' }); + + const cfg = await h.gv_get_view_config({ id: viewId }); + const section = cfg.widgets?.header_top?.[widget.slot]?.search_fields_section; + TestAssert.isNotNull(section, 'search_fields_section present after legacy save'); + const row = section['search-general_top::100::roundtrip_row']; + TestAssert.isNotNull(row, 'position key survived legacy save'); + TestAssert.isNotNull(row?.roundtrip_slot, 'MCP-written slot UID survived legacy save'); + TestAssert.equal(row.roundtrip_slot.input_type, 'input_text', 'input_type survived legacy save'); +}); + +// ============================================================ +// HOSTILE STRESS TESTS — actively try to break the API surface +// +// Every test here either confirms the surface holds under abuse, +// or fails loudly when behaviour is wrong (silent success on bad +// input, 500 instead of 400, torn reads, lost writes, etc.). +// Tests are deliberately ruthless — see the prompt for the full +// matrix. Tests are NOT modified to "match reality" when they +// uncover a bug — they're left failing with a clear message so +// the bug shows up in the suite output. +// ============================================================ + +/** Convenience: pull HTTP status / WP error code off a thrown axios error. */ +function errStatus(err) { return err?.response?.status ?? null; } +function errCode(err) { return err?.response?.data?.code ?? null; } +function errMessage(err) { + return String(err?.response?.data?.message ?? err?.message ?? ''); +} + +/** Run an apply that we expect to throw; return the captured error info. */ +async function expectApplyError(args) { + try { + const result = await h.gv_apply_view_config(args); + return { thrown: false, result }; + } catch (err) { + return { + thrown: true, + status: errStatus(err), + code: errCode(err), + message: errMessage(err), + }; + } +} + +// ---------- CONCURRENCY chaos ---------- + +suite.test('[hostile] Concurrency: 10 parallel applies with same If-Match → exactly 1 wins', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-conc-10'); + const config = await h.gv_get_view_config({ id: viewId }); + const etag = `"${config.version}"`; + const N = 10; + + const results = await Promise.allSettled( + Array.from({ length: N }, (_, i) => + h.gv_apply_view_config({ + id: viewId, + ifMatch: etag, + mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: `parc${String(i).padStart(3, '0')}` }] }, + }) + ) + ); + const accepted = results.filter((r) => r.status === 'fulfilled').length; + const rejected = results.filter((r) => r.status === 'rejected' && (errStatus(r.reason) === 412 || /412|precondition/i.test(errMessage(r.reason)))).length; + TestAssert.equal(accepted, 1, `expected exactly 1 accepted write out of ${N}, got ${accepted}`); + TestAssert.equal(rejected, N - 1, `expected ${N - 1} 412 rejections, got ${rejected}`); +}); + +suite.test('[hostile] Concurrency: 50 parallel reads of gv_get_view_config all succeed', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-50-reads'); + const N = 50; + const results = await Promise.allSettled( + Array.from({ length: N }, () => h.gv_get_view_config({ id: viewId })) + ); + const ok = results.filter((r) => r.status === 'fulfilled' && r.value?.view_id === viewId).length; + TestAssert.equal(ok, N, `all ${N} reads should succeed, got ${ok}`); +}); + +suite.test('[hostile] Concurrency: 20 interleaved apply+read pairs — no torn reads', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-interleaved'); + const N = 20; + + // Sequential apply→read pairs (no ifMatch — abilities path doesn't + // wire the GV client version cache, and we want every write to land + // unconditionally so we can assert each one is visible immediately). + for (let i = 0; i < N; i++) { + const slotUid = `tear${String(i).padStart(3, '0')}`; + await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: slotUid, custom_label: `Iter ${i}` }] }, + }); + const cfg = await h.gv_get_view_config({ id: viewId }); + const slot = cfg.fields?.['directory_list-title']?.[slotUid]; + TestAssert.isNotNull(slot, `iteration ${i}: slot ${slotUid} must be present in subsequent read`); + TestAssert.equal(slot.custom_label, `Iter ${i}`, `iteration ${i}: custom_label torn`); + } +}); + +suite.test('[hostile] Concurrency: two writers racing on different slots in same area both land', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-two-writers'); + const cfg = await h.gv_get_view_config({ id: viewId }); + const etag = `"${cfg.version}"`; + + // Same etag, but distinct slot UIDs in the same area. + const results = await Promise.allSettled([ + h.gv_apply_view_config({ + id: viewId, + ifMatch: etag, + mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'race_a' }] }, + }), + h.gv_apply_view_config({ + id: viewId, + ifMatch: etag, + mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.email, slot: 'race_b' }] }, + }), + ]); + // Optimistic concurrency means with same ifMatch, only one wins. The other + // gets 412 — the caller must retry. Document this contract. + const accepted = results.filter((r) => r.status === 'fulfilled').length; + TestAssert.equal(accepted, 1, 'optimistic concurrency: only 1 same-etag write lands per round'); + + // Now retry the rejected write WITHOUT ifMatch (the docs say omit = bypass). + await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.email, slot: 'race_b' }] }, + }); + const final = await h.gv_get_view_config({ id: viewId }); + TestAssert.isNotNull(final.fields?.['directory_list-title']?.race_a, 'race_a landed'); + TestAssert.isNotNull(final.fields?.['directory_list-title']?.race_b, 'race_b landed after retry without ifMatch'); +}); + +// ---------- HUGE PAYLOADS ---------- + +suite.test('[hostile] Huge payload: 200 fields in a single apply', async () => { + if (suite.skip) return; + console.log(' (building 200-field payload…)'); + const viewId = await mintView('hostile-huge-payload-200-fields'); + const slots = Array.from({ length: 200 }, (_, i) => ({ + field_id: fieldIds.name, + slot: `huge${String(i).padStart(4, '0')}`, + custom_label: `Slot #${i}`, + })); + const t0 = Date.now(); + let result, err; + try { + result = await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + fields: { 'directory_list-title': slots }, + }); + } catch (e) { err = e; } + console.log(` (200-field apply took ${Date.now() - t0}ms)`); + + if (err) { + // A 413 / 400 with a meaningful "too large" code is acceptable. + const status = errStatus(err); + TestAssert.isTrue( + status === 400 || status === 413, + `200-field apply failed with non-sane status ${status}: ${errMessage(err)}` + ); + return; + } + TestAssert.isNotNull(result.applied, '200-field apply succeeded'); + // Spot-check the round trip — pick start/end/middle. + const cfg = await h.gv_get_view_config({ id: viewId }); + const titleArea = cfg.fields?.['directory_list-title'] || {}; + TestAssert.isNotNull(titleArea.huge0000, 'first slot present'); + TestAssert.isNotNull(titleArea.huge0099, 'middle slot present'); + TestAssert.isNotNull(titleArea.huge0199, 'last slot present'); +}); + +suite.test('[hostile] Huge payload: custom_content with 50KB HTML body survives', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-50kb-custom-content'); + const big = '

' + 'lorem ipsum dolor sit amet '.repeat(2000) + '

'; // ~ 53 KB + const stored = await roundTripSlot(viewId, 'directory_list-description', 'huge50kb', { + field_id: 'custom', + content: big, + wpautop: false, + }); + TestAssert.isTrue(stored.content && stored.content.length >= 50 * 1000, `content survived (got ${stored.content?.length ?? 0} bytes)`); +}); + +suite.test('[hostile] Huge payload: conditional_logic with 100 nested rules', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-cl-100-rules'); + const rules = Array.from({ length: 100 }, (_, i) => ({ + fieldId: fieldIds.name, operator: 'is', value: `v${i}`, + })); + const cl = { version: 2, actionType: 'show', logicType: 'all', rules }; + let stored, err; + try { + stored = await roundTripSlot(viewId, 'directory_list-title', 'cl100', { + field_id: fieldIds.name, + conditional_logic: cl, + }); + } catch (e) { err = e; } + if (err) { + const status = errStatus(err); + TestAssert.isTrue(status === 400 || status === 413, `100-rule CL must reject cleanly, got status ${status}: ${errMessage(err)}`); + return; + } + TestAssert.isNotNull(stored.conditional_logic, '100-rule CL persisted'); + const decoded = typeof stored.conditional_logic === 'string' + ? JSON.parse(stored.conditional_logic) + : stored.conditional_logic; + TestAssert.equal(Array.isArray(decoded.rules) ? decoded.rules.length : 0, 100, 'all 100 rules persisted'); +}); + +suite.test('[hostile] Huge payload: search bar with 50 search fields across 5 positions', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-50-search-fields'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + const positions = ['10', '20', '30', '40', '50']; + for (const pos of positions) { + for (let i = 0; i < 10; i++) { + try { + await h.gv_add_search_field({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: `search-general_top::${pos}::row_${pos}`, + slot: `s_${pos}_${i}`, + field: { id: fieldIds.name, input: 'input_text', label: `f${i}@${pos}` }, + }); + } catch (e) { + throw new Error(`search field ${pos}/${i} failed: ${errStatus(e)} ${errMessage(e)}`); + } + } + } + const cfg = await h.gv_get_view_config({ id: viewId }); + const section = cfg.widgets?.header_top?.[widget.slot]?.search_fields_section || {}; + let total = 0; + for (const row of Object.values(section)) total += Object.keys(row || {}).length; + TestAssert.equal(total, 50, `expected 50 search fields stored, got ${total}`); +}); + +// ---------- MALFORMED INPUTS ---------- + +suite.test('[hostile] Malformed: fields = string-not-object → 400', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-fields-string'); + const r = await expectApplyError({ id: viewId, mode: 'merge', fields: 'not-an-object' }); + TestAssert.isTrue(r.thrown, 'string fields must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `status must be 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: fields = { area: "not-an-array" } → 400', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-area-string'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': 'not-an-array' }, + }); + TestAssert.isTrue(r.thrown, 'string-typed area value must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `status must be 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: field_id = null → reject (4xx, no 500)', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-field-null'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: null, slot: 'fnull01' }] }, + }); + TestAssert.isTrue(r.thrown, 'null field_id must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `status must be 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: field_id = object → reject', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-field-obj'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: { evil: true }, slot: 'fobj01' }] }, + }); + TestAssert.isTrue(r.thrown, 'object field_id must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `status must be 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: ifMatch = empty string is treated as no-precondition', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-ifmatch-empty'); + // Empty string ifMatch should be a no-op (not a 412, not a 500). + const r = await expectApplyError({ + id: viewId, mode: 'merge', ifMatch: '', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'iem01' }] }, + }); + TestAssert.isTrue(!r.thrown, `empty ifMatch should not throw (got ${r.status} ${r.message})`); +}); + +suite.test('[hostile] Malformed: ifMatch = "" (literal quoted empty) → 412 or treated as no-op cleanly', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-ifmatch-quoted-empty'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', ifMatch: '""', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'ieq01' }] }, + }); + // Either accept (no version match attempt) or reject 412 — but never 5xx. + if (r.thrown) { + TestAssert.isTrue(r.status === 412 || (r.status >= 400 && r.status < 500), `status must be sane, got ${r.status}: ${r.message}`); + } +}); + +suite.test('[hostile] Malformed: ifMatch = whitespace only → no 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-ifmatch-ws'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', ifMatch: ' ', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'iws01' }] }, + }); + if (r.thrown) { + TestAssert.isTrue(r.status >= 400 && r.status < 500, `status must be 4xx, got ${r.status}: ${r.message}`); + } +}); + +suite.test('[hostile] Malformed: ifMatch = giant string (10KB) → 4xx, not 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-ifmatch-giant'); + const giant = '"' + 'A'.repeat(10000) + '"'; + const r = await expectApplyError({ + id: viewId, mode: 'merge', ifMatch: giant, + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'ig01' }] }, + }); + // Will mismatch → 412. + TestAssert.isTrue(r.thrown, 'giant ifMatch must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `must 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: ifMatch = SQL injection attempt → safe rejection', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-ifmatch-sqli'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + ifMatch: `"' OR '1'='1"; DROP TABLE wp_posts; --"`, + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'sqli01' }] }, + }); + TestAssert.isTrue(r.thrown, 'malicious ifMatch must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `must be 4xx, got ${r.status}: ${r.message}`); + // Confirm the view is still alive afterwards (no DB damage). + const cfg = await h.gv_get_view_config({ id: viewId }); + TestAssert.equal(cfg.view_id, viewId, 'view still readable after SQLi attempt'); +}); + +suite.test('[hostile] Malformed: area key with newlines/tabs/null bytes → 400 with invalid_area_key', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-area-newlines'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title\n\tevil': [{ field_id: fieldIds.name, slot: 'ank01' }] }, + }); + TestAssert.isTrue(r.thrown, 'must reject control-char area key'); + TestAssert.equal(r.status, 400, `must 400, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: area key 10000 chars long → 400', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-area-long'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { ['directory_' + 'a'.repeat(10000)]: [{ field_id: fieldIds.name, slot: 'al01' }] }, + }); + TestAssert.isTrue(r.thrown, 'must reject huge area key'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `must 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: slot uid with path traversal "../../../etc/passwd"', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-slot-traversal'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: '../../../etc/passwd' }] }, + }); + // Server should normalise / reject. If it accepts, ensure no filesystem + // surprise — the slot must just be a string key, not a real path. + if (!r.thrown) { + const cfg = await h.gv_get_view_config({ id: viewId }); + const area = cfg.fields?.['directory_list-title'] || {}; + const keys = Object.keys(area); + TestAssert.isTrue(keys.length > 0, 'something stored'); + // Whatever the slot got rewritten to, it MUST not contain raw "../" + // (a downstream consumer might use it as a path component). + for (const k of keys) { + TestAssert.isTrue(!k.includes('../'), `slot key "${k}" must not contain "../"`); + } + } else { + TestAssert.isTrue(r.status >= 400 && r.status < 500, `if rejected, must 4xx (got ${r.status})`); + } +}); + +suite.test('[hostile] Sanitisation: custom_label with Hello', + }); + TestAssert.isTrue(!String(stored.custom_label || '').includes(' must be stripped, got "${stored.custom_label}"`); + TestAssert.isTrue(String(stored.custom_label || '').includes('Hello'), 'inner text "Hello" survives'); +}); + +suite.test('[hostile] Sanitisation: custom_label with NUL/control chars/RTL override → no NUL/RTLO survives', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-control-label'); + const evil = 'safenul‎rtl‮flipbell'; + const stored = await roundTripSlot(viewId, 'directory_list-title', 'ccl01', { + field_id: fieldIds.name, + custom_label: evil, + }); + const out = String(stored.custom_label || ''); + // NUL bytes must not survive — they break log lines, JSON serialisers, etc. + TestAssert.isTrue(!out.includes(''), `NUL byte must be stripped (got "${JSON.stringify(out)}")`); + // BEL is a control char that should also not survive in a plain text setting. + TestAssert.isTrue(!out.includes(''), `BEL must be stripped (got "${JSON.stringify(out)}")`); +}); + +suite.test('[hostile] Edge: field_id = "0" (zero) → either reject or treat as numeric 0 form field', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-field-zero'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: '0', slot: 'fz01' }] }, + }); + if (r.thrown) { + TestAssert.isTrue(r.status >= 400 && r.status < 500, `field_id 0 reject must be 4xx (got ${r.status})`); + } + // If accepted, view must still be readable. + const cfg = await h.gv_get_view_config({ id: viewId }); + TestAssert.equal(cfg.view_id, viewId, 'view readable after field_id=0'); +}); + +suite.test('[hostile] Edge: field_id = "-1" → reject or accept without 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-field-neg'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: '-1', slot: 'fn01' }] }, + }); + if (r.thrown) { + TestAssert.isTrue(r.status >= 400 && r.status < 500, `field_id -1 reject must be 4xx (got ${r.status})`); + } +}); + +suite.test('[hostile] Edge: field_id = "1.2.3.4" (IP-like) → no 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-field-iplike'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: '1.2.3.4', slot: 'fi01' }] }, + }); + if (r.thrown) { + TestAssert.isTrue(r.status >= 400 && r.status < 500, `IP-like field_id reject must be 4xx (got ${r.status})`); + } +}); + +suite.test('[hostile] CL: invalid JSON string → warning, value dropped, no 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-cl-bad-json'); + const apply = await h.gv_apply_view_config({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ + field_id: fieldIds.name, slot: 'clbj01', + conditional_logic: '{ this is not json', + }] }, + }); + TestAssert.isTrue(Array.isArray(apply.warnings), 'warnings array must be present for bad CL'); + const match = apply.warnings.find((w) => w.slot === 'clbj01' && w.key === 'conditional_logic'); + TestAssert.isNotNull(match, `bad-JSON CL must surface a warning (got ${JSON.stringify(apply.warnings)})`); +}); + +suite.test('[hostile] CL: valid JSON of wrong shape (e.g. array) → warning, value dropped', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-cl-wrong-shape'); + const apply = await h.gv_apply_view_config({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ + field_id: fieldIds.name, slot: 'clws01', + conditional_logic: '[1, 2, 3]', + }] }, + }); + TestAssert.isTrue(Array.isArray(apply.warnings), 'warnings array must be present'); + const match = apply.warnings.find((w) => w.slot === 'clws01' && w.key === 'conditional_logic'); + TestAssert.isNotNull(match, `array-shape CL must warn (got ${JSON.stringify(apply.warnings)})`); +}); + +suite.test('[hostile] Unicode: zalgo-text custom_label round-trips', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-zalgo'); + const zalgo = 'Z̸̢̧̛̛̮̪̱͚̑̄̀͆a̵̧͉̭̱̾̾̊̏l̶̢̧̪̟̥͉̃̃̄g̶̢̗̱͉̑̄̾͂o̸̧̮̪̭̭̾̾̃̃'; + const stored = await roundTripSlot(viewId, 'directory_list-title', 'zlg01', { + field_id: fieldIds.name, + custom_label: zalgo, + }); + TestAssert.equal(stored.custom_label, zalgo); +}); + +suite.test('[hostile] Unicode: emoji-only custom_label', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-emoji-only'); + const emoji = '🔥🎉🚀💥🐉🦄🌈'; + const stored = await roundTripSlot(viewId, 'directory_list-title', 'emo01', { + field_id: fieldIds.name, + custom_label: emoji, + }); + TestAssert.equal(stored.custom_label, emoji); +}); + +// ---------- INPUT TYPE EDGE CASES ---------- + +suite.test('[hostile] Search input: leading/trailing whitespace (" input_text ") → reject or trim cleanly', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-search-whitespace'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + let status = null; + try { + await h.gv_add_search_field({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: 'search-general_top::100::ws_row', + field: { id: fieldIds.name, input: ' input_text ', label: 'WS' }, + }); + } catch (e) { status = errStatus(e); } + // Either pre-flight rejects (acceptable) or server rejects (acceptable). + // 5xx is NEVER acceptable. + if (status !== null) { + TestAssert.isTrue(status >= 400 && status < 500, `whitespace input slug must 4xx, got ${status}`); + } +}); + +suite.test('[hostile] Search input: wrong-case ("INPUT_TEXT") → reject or normalise', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-search-case'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + let status = null; + try { + await h.gv_add_search_field({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: 'search-general_top::100::case_row', + field: { id: fieldIds.name, input: 'INPUT_TEXT', label: 'CASE' }, + }); + } catch (e) { status = errStatus(e); } + if (status !== null) { + TestAssert.isTrue(status === 400, `wrong-case input slug must 400, got ${status}`); + } +}); + +suite.test('[hostile] Search input: numeric (1) instead of string', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-search-numeric-input'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + let status = null; + try { + await h.gv_add_search_field({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: 'search-general_top::100::num_row', + field: { id: fieldIds.name, input: 1, label: 'NUM' }, + }); + } catch (e) { status = errStatus(e); } + if (status !== null) { + TestAssert.isTrue(status >= 400 && status < 500, `numeric input must 4xx, got ${status}`); + } +}); + +suite.test('[hostile] Search field: field.id = 0 → reject', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-searchfield-zero'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + let status = null; + try { + await h.gv_add_search_field({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: 'search-general_top::100::z_row', + field: { id: 0, input: 'input_text', label: 'Z' }, + }); + } catch (e) { status = errStatus(e); } + if (status !== null) { + TestAssert.isTrue(status >= 400 && status < 500, `field.id=0 must 4xx, got ${status}`); + } +}); + +suite.test('[hostile] Search field: field.id pointing at a deleted/non-existent GF field', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-searchfield-ghost'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_add_view_widget({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + let status = null; + try { + await h.gv_add_search_field({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: 'search-general_top::100::g_row', + field: { id: 99999, input: 'input_text', label: 'Ghost' }, + }); + } catch (e) { status = errStatus(e); } + // Ideally rejects; if it accepts (because the value isn't validated until render), + // confirm no 5xx happened. + if (status !== null) { + TestAssert.isTrue(status >= 400 && status < 500, `ghost field reject must 4xx, got ${status}`); + } +}); + +// ---------- WIDGET / SEARCH chaos ---------- + +suite.test('[hostile] Widget: bogus widget id (definitely_not_real) → reject', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-widget-bogus-id'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + let status = null; + try { + await h.gv_add_view_widget({ + id: viewId, area: 'header_top', + widget: { field_id: 'definitely_not_a_real_widget', label: 'X' }, + }); + } catch (e) { status = errStatus(e); } + if (status !== null) { + TestAssert.isTrue(status >= 400 && status < 500, `bogus widget reject must 4xx, got ${status}`); + } +}); + +suite.test('[hostile] area_settings injected as a "field" must NOT be treated as a slot', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-area-settings-injection'); + // area_settings is a meta key on the area itself, not a slot. + // A slot literally named "area_settings" should either reject or be + // namespaced so it doesn't clobber real area_settings. + await h.gv_apply_view_config({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'area_settings' }] }, + }); + const cfg = await h.gv_get_view_config({ id: viewId }); + // If "area_settings" got stored as a real slot key, it MUST have a field_id — + // otherwise it's been confused with the area-level settings envelope. + const stored = cfg.fields?.['directory_list-title']?.area_settings; + if (stored) { + TestAssert.isTrue( + 'field_id' in stored || 'id' in stored, + `slot "area_settings" must look like a slot, not an envelope (got ${JSON.stringify(stored)})` + ); + } +}); + +// ---------- TEMPLATE SETTINGS edge cases ---------- + +suite.test('[hostile] template-settings: page_size = 0 → coerced or rejected, no 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-pagesize-0'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + let err; + try { + await h.gv_patch_view_settings({ + id: viewId, template_settings: { page_size: 0 }, + }); + } catch (e) { err = e; } + if (err) { + TestAssert.isTrue(errStatus(err) >= 400 && errStatus(err) < 500, `page_size=0 reject must 4xx, got ${errStatus(err)}: ${errMessage(err)}`); + } +}); + +suite.test('[hostile] template-settings: page_size = -1 → coerced or rejected', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-pagesize-neg'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + let err; + try { + await h.gv_patch_view_settings({ + id: viewId, template_settings: { page_size: -1 }, + }); + } catch (e) { err = e; } + if (err) { + TestAssert.isTrue(errStatus(err) >= 400 && errStatus(err) < 500, `page_size=-1 reject must 4xx, got ${errStatus(err)}: ${errMessage(err)}`); + } +}); + +suite.test('[hostile] template-settings: page_size = "abc" → 4xx or coerced to 0/default', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-pagesize-string'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + let err; + try { + await h.gv_patch_view_settings({ + id: viewId, template_settings: { page_size: 'abc' }, + }); + } catch (e) { err = e; } + if (err) { + TestAssert.isTrue(errStatus(err) >= 400 && errStatus(err) < 500, `page_size="abc" reject must 4xx, got ${errStatus(err)}: ${errMessage(err)}`); + } else { + const cfg = await h.gv_get_view_config({ id: viewId }); + const ps = cfg.template_settings?.page_size; + TestAssert.isTrue( + ps === undefined || ps === null || ps === 0 || /^\d+$/.test(String(ps)), + `page_size after "abc" must be numeric or absent, got ${JSON.stringify(ps)}` + ); + } +}); + +suite.test('[hostile] template-settings: 100 keys at once', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-100-template-keys'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const ts = {}; + for (let i = 0; i < 100; i++) ts[`unknown_key_${i}`] = `v${i}`; + let err; + try { + await h.gv_patch_view_settings({ id: viewId, template_settings: ts }); + } catch (e) { err = e; } + if (err) { + TestAssert.isTrue(errStatus(err) >= 400 && errStatus(err) < 500, `bulk-template-settings must 4xx, got ${errStatus(err)}`); + } +}); + +// ---------- RENDER hot path ---------- + +suite.test('[hostile] Render: 50 parallel staged_slot renders', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-render-50-parallel'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const N = 50; + const t0 = Date.now(); + const results = await Promise.allSettled( + Array.from({ length: N }, (_, i) => h.gv_render_view_field({ + id: viewId, area: 'directory_table-columns', + slot: `parallel${String(i).padStart(3, '0')}`, + staged_slot: { field_id: fieldIds.name, custom_label: `Parallel ${i}`, show_label: '1' }, + })) + ); + console.log(` (50 parallel renders took ${Date.now() - t0}ms)`); + // Allow 503 (no entries) — but never 5xx other than 503, never 4xx unless 404. + let okOrEmpty = 0, problems = []; + for (const r of results) { + if (r.status === 'fulfilled') { okOrEmpty++; continue; } + const s = errStatus(r.reason); + if (s === 503) { okOrEmpty++; continue; } + problems.push(`status ${s}: ${errMessage(r.reason)}`); + } + TestAssert.equal(problems.length, 0, `parallel renders had ${problems.length} unexpected problems: ${problems.slice(0, 3).join(' | ')}`); +}); + +suite.test('[hostile] Render: extremely long custom_label (10KB) — no 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-render-long-label'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + const longLabel = 'L'.repeat(10000); + let status = null; + try { + await h.gv_render_view_field({ + id: viewId, area: 'directory_table-columns', slot: 'longlabel001', + staged_slot: { field_id: fieldIds.name, custom_label: longLabel, show_label: '1' }, + }); + } catch (e) { status = errStatus(e); } + TestAssert.isTrue(status === null || status === 503 || (status >= 400 && status < 500), + `long-label render must succeed/4xx/503 only, got ${status}`); +}); + +// ---------- RACE / CROSS-VIEW ---------- + +suite.test('[hostile] Apply to a deleted view → 404 (not 500)', async () => { + if (suite.skip) return; + // Needs wp-cli — the WP REST `gravityview` post type isn't exposed + // for DELETE, so we have to go through wp-cli or skip cleanly. + const wpRoot = process.env.WP_ROOT; + let cp; + if (wpRoot) { + try { + cp = await import('node:child_process'); + cp.execSync('which wp', { stdio: 'pipe' }); + } catch (_) { cp = null; } + } + if (!cp || !wpRoot) { + console.log(' (skipping — needs WP_ROOT + wp-cli to actually delete the view)'); + return; + } + + const viewId = await mintView('hostile-apply-after-delete'); + // Trash + force-delete via wp-cli. + cp.execSync(`cd ${wpRoot} && wp post delete ${viewId} --force`, { stdio: 'pipe' }); + // Drop from cleanup tracking — already gone. + mintedViewIds = mintedViewIds.filter((v) => v !== viewId); + + let status = null; + let message = ''; + try { + await h.gv_apply_view_config({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'd01' }] }, + }); + } catch (e) { status = errStatus(e); message = errMessage(e); } + TestAssert.equal(status, 404, `deleted view apply must 404 (got ${status}: ${message})`); +}); + +suite.test('[hostile] Cross-view ifMatch → 412', async () => { + if (suite.skip) return; + // Mint view A, mutate it so its version counter bumps to ":1+", then mint B. + // This guarantees A's etag is genuinely incompatible with B's fresh ":0". + const viewA = await mintView('hostile-xview-A'); + await h.gv_apply_view_config({ + id: viewA, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'bump_a' }] }, + }); + const cfgA = await h.gv_get_view_config({ id: viewA }); + const etagA = `"${cfgA.version}"`; + + const viewB = await mintView('hostile-xview-B'); + const cfgB = await h.gv_get_view_config({ id: viewB }); + // Sanity: versions must differ — otherwise the test can't prove anything. + TestAssert.isTrue(cfgA.version !== cfgB.version, `view versions must differ; got A=${cfgA.version} B=${cfgB.version}`); + + let status = null, message = ''; + try { + await h.gv_apply_view_config({ + id: viewB, ifMatch: etagA, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'xvm01' }] }, + }); + } catch (e) { status = errStatus(e); message = errMessage(e); } + TestAssert.equal(status, 412, `cross-view ifMatch must 412 (got ${status}: ${message})`); +}); + +suite.test('[hostile] Same apply twice with same ifMatch → second 412', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-replay'); + const cfg = await h.gv_get_view_config({ id: viewId }); + const etag = `"${cfg.version}"`; + await h.gv_apply_view_config({ + id: viewId, ifMatch: etag, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'rep01' }] }, + }); + let status = null; + try { + await h.gv_apply_view_config({ + id: viewId, ifMatch: etag, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'rep02' }] }, + }); + } catch (e) { status = errStatus(e); } + TestAssert.equal(status, 412, `replay must 412 (got ${status})`); +}); + +// ---------- RECURSIVE / SELF-REFERENTIAL ---------- + +suite.test('[hostile] Self-ref: field with conditional_logic referencing itself round-trips', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-cl-selfref'); + // CL references the same field — the renderer should handle this without + // infinite recursion. The setting itself should at least round-trip. + const cl = { + version: 2, actionType: 'show', logicType: 'all', + rules: [{ fieldId: fieldIds.name, operator: 'is', value: 'self' }], + }; + const stored = await roundTripSlot(viewId, 'directory_list-title', 'sref01', { + field_id: fieldIds.name, conditional_logic: cl, + }); + TestAssert.isNotNull(stored.conditional_logic, 'self-ref CL persisted'); +}); + +suite.test('[hostile] Self-ref: custom_content embedding [gravityview id=""] shortcode', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-shortcode-selfref'); + const stored = await roundTripSlot(viewId, 'directory_list-description', 'shsref01', { + field_id: 'custom', + content: `[gravityview id="${viewId}"]`, + wpautop: false, + }); + TestAssert.isTrue(String(stored.content || '').includes(`id="${viewId}"`), 'shortcode body persisted verbatim'); + // The /render path is the place a recursion bomb would explode — skip + // hitting it here on purpose; persistence is the contract being asserted. +}); + +// ============================================================ +// AESTHETIC REFACTOR — new + consolidated abilities +// (renamed handlers are exercised throughout the suite via the +// global sweep — these tests exclusively cover surfaces that +// didn't exist before this pass.) +// ============================================================ + +suite.test('Consolidated schema: gv_get_view_field_schemas with no filter returns the bulk map', async () => { + if (suite.skip) return; + const viewId = await mintView('schema bulk mode'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_apply_view_config({ + id: viewId, + fields: { 'directory_table-columns': [ + { field_id: fieldIds.name, slot: 'sb_a' }, + { field_id: fieldIds.email, slot: 'sb_b' }, + ] }, + mode: 'merge', + }); + const r = await h.gv_get_view_field_schemas({ id: viewId }); + TestAssert.isTrue(typeof r.schemas === 'object', 'schemas object present'); + const keys = Object.keys(r.schemas); + TestAssert.isTrue(keys.length >= 2, `bulk returned at least the 2 placed slots (got ${keys.length})`); +}); + +suite.test('Consolidated schema: gv_get_view_field_schemas filtered to area+slot returns one-key map', async () => { + if (suite.skip) return; + const viewId = await mintView('schema single mode'); + await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_apply_view_config({ + id: viewId, + fields: { 'directory_table-columns': [ + { field_id: fieldIds.name, slot: 'sb_solo' }, + { field_id: fieldIds.email, slot: 'sb_other' }, + ] }, + mode: 'merge', + }); + const r = await h.gv_get_view_field_schemas({ + id: viewId, + area: 'directory_table-columns', + slot: 'sb_solo', + }); + const keys = Object.keys(r.schemas); + TestAssert.equal(keys.length, 1, 'single-slot filter narrows to ONE entry'); + TestAssert.equal(keys[0], 'directory_table-columns/sb_solo', 'returned key matches the requested area/slot'); +}); + +suite.test('Safety: NO permanent-delete ability exists — only set-view-status: trash is the soft-remove path', async () => { + if (suite.skip) return; + // Anti-test: delete-view was intentionally NOT shipped. AI agents + // shouldn't have a one-call permanent destruction path. Soft-delete + // via set-view-status: trash is the canonical path; recovery is via + // WP admin's "Restore from trash". This test pins that contract. + TestAssert.isTrue( + typeof h.gv_delete_view === 'undefined', + 'gv_delete_view ability must NOT exist (permanent destruction is admin-only by design)', + ); +}); + +suite.test('Safety: set-view-status: trash works as the soft-remove path (recoverable)', async () => { + if (suite.skip) return; + const viewId = await mintView('soft-trash via set-status'); + const r = await h.gv_set_view_status({ id: viewId, status: 'trash' }); + TestAssert.equal(r.changed, true); + TestAssert.equal(r.status, 'trash'); + // Trashed views are still in the post table — recoverable. The + // get_post_status check is the canonical "is this trashed" probe. + // We assert via wp-cli rather than a REST round-trip because the + // trashed view's accessibility through gv_get_view_config is a + // separate contract. +}); + +suite.test('Safety: abilities-loader does NOT add a client-side destructive gate', async () => { + if (suite.skip) return; + // Field/widget removal is normal authoring (and reversible — re-add + // the same field_id). The server's permission_callback (edit_post / + // edit_gravityviews) is the only protection layer; there must NOT be + // an env-var refusal in the loader closure. We probe by ensuring the + // handler contacts the server (a 4xx from auth/missing-resource is + // proof we got past the loader). The previous "Refusing to call + // destructive ability" gate was removed when DeleteView shipped — + // there is no longer any "delete the whole View" path through the + // ability registry, so the env-var ratchet served no remaining + // purpose. Status-level removal still flows through gv_set_view_status + // and is gated server-side by the WP `delete_post` capability. + const { loadAbilitiesAsTools } = await import('../view-operations/abilities-loader.js'); + const { handlers } = await loadAbilitiesAsTools(gvClient); + let msg = ''; + try { + await handlers.gv_remove_view_field({ id: 999999999, area: 'directory_list-title', slot: 'never' }); + } catch (err) { + msg = String(err?.message ?? ''); + } + TestAssert.isTrue( + !msg.includes('Refusing to call destructive ability'), + `loader must not refuse destructive calls client-side (got "${msg}")`, + ); +}); + +suite.test('New ability: gv_duplicate_view clones form + template + fields', async () => { + if (suite.skip) return; + const sourceId = await mintView('duplicate source'); + await h.gv_patch_view_template({ id: sourceId, template_id: 'default_table' }); + await h.gv_apply_view_config({ + id: sourceId, + fields: { 'directory_table-columns': [ + { field_id: fieldIds.name, slot: 'dup_a', custom_label: 'Carry-over' }, + ] }, + mode: 'merge', + }); + + const r = await h.gv_duplicate_view({ id: sourceId, title: 'Duplicated for stress test' }); + TestAssert.equal(r.duplicated, true); + TestAssert.equal(r.source_id, sourceId); + TestAssert.isTrue(r.view_id > 0 && r.view_id !== sourceId, 'fresh post id, distinct from source'); + TestAssert.equal(r.title, 'Duplicated for stress test'); + mintedViewIds.push(r.view_id); + + const dup = await h.gv_get_view_config({ id: r.view_id }); + TestAssert.equal(dup.form_id, Number(formId), 'form binding cloned'); + TestAssert.equal(dup.template_id, 'default_table', 'template cloned'); + TestAssert.equal( + dup.fields['directory_table-columns']?.dup_a?.custom_label, + 'Carry-over', + 'field placement + custom_label cloned', + ); +}); + +suite.test('New ability: gv_set_view_status — publish → draft round-trip + idempotent re-set', async () => { + if (suite.skip) return; + const viewId = await mintView('set-status round-trip'); + + const pub = await h.gv_set_view_status({ id: viewId, status: 'publish' }); + TestAssert.equal(pub.status, 'publish'); + TestAssert.equal(pub.previous_status, 'draft'); + TestAssert.equal(pub.changed, true); + + const noop = await h.gv_set_view_status({ id: viewId, status: 'publish' }); + TestAssert.equal(noop.changed, false, 'idempotent — same status returns changed: false'); + + const draft = await h.gv_set_view_status({ id: viewId, status: 'draft' }); + TestAssert.equal(draft.status, 'draft'); + TestAssert.equal(draft.previous_status, 'publish'); + TestAssert.equal(draft.changed, true); +}); + +suite.test('New ability: gv_set_view_status rejects an invalid status enum value', async () => { + if (suite.skip) return; + const viewId = await mintView('set-status invalid'); + let status = null; + try { + await h.gv_set_view_status({ id: viewId, status: 'totally_made_up' }); + } catch (err) { + status = err?.response?.status ?? null; + } + TestAssert.equal(status, 400, 'invalid status → 400'); +}); + +suite.run(); diff --git a/src/tests/views.test.js b/src/tests/views.test.js index e15d2e0..568edaf 100644 --- a/src/tests/views.test.js +++ b/src/tests/views.test.js @@ -4,7 +4,7 @@ * Mirrors `forms.test.js` shape: MockHttpClient for the transport, * scenarios cover happy path, negative client-side validation, and * the inspector-specific behaviours (If-Match optimistic-concurrency, - * mode replace/merge, area-key URL encoding, allowDelete guard). + * mode replace/merge, area-key URL encoding). */ import { GravityViewClient } from '../gravityview-client.js'; @@ -24,12 +24,9 @@ let mockHttpClient; let testEnv; suite.beforeEach(() => { - testEnv = { - ...setupTestEnvironment(), - // GravityViewClient falls back to GRAVITY_FORMS_* creds, so the - // shared setupTestEnvironment values cover both surfaces. - GRAVITYVIEW_ALLOW_DELETE: 'true', - }; + // GravityViewClient falls back to GRAVITY_FORMS_* creds, so the + // shared setupTestEnvironment values cover both surfaces. + testEnv = setupTestEnvironment(); mockHttpClient = new MockHttpClient(); client = new GravityViewClient(testEnv); client.httpClient = mockHttpClient; // bypass real network @@ -75,15 +72,21 @@ suite.test('Constructor: falls back to GRAVITY_FORMS_CONSUMER_KEY/SECRET', () => // Discovery // ==================================================================== -suite.test('listTemplates: returns the templates array', async () => { +suite.test('listLayouts: returns the layouts array', async () => { mockHttpClient.setMockResponse( 'GET', - '/templates', - new MockResponse({ templates: [{ id: 'default_list' }, { id: 'default_table' }] }) + '/layouts', + new MockResponse({ + layouts: [ + { id: 'gravityview-layout-builder', label: 'Layout Builder', is_grid_aware: true }, + { id: 'diy', label: 'DIY', is_grid_aware: false }, + ], + }) ); - const data = await client.listTemplates(); - TestAssert.equal(data.templates.length, 2); - TestAssert.equal(data.templates[0].id, 'default_list'); + const data = await client.listLayouts(); + TestAssert.equal(data.layouts.length, 2); + TestAssert.equal(data.layouts[0].id, 'gravityview-layout-builder'); + TestAssert.equal(data.layouts[0].is_grid_aware, true); }); suite.test('getFieldTypeSchema: requires field_type', async () => { @@ -238,16 +241,11 @@ suite.test('moveViewField: posts to /fields/_move with from/to/position', async TestAssert.equal(req.config.data.position, 0); }); -suite.test('removeViewField: rejected when allowDelete is false', async () => { - client.allowDelete = false; - await TestAssert.throwsAsync( - () => client.removeViewField({ id: 9, area: 'directory_list-title', slot: 'slot1' }), - 'GRAVITYVIEW_ALLOW_DELETE' - ); -}); - -suite.test('removeViewField: deletes when allowDelete is true', async () => { - client.allowDelete = true; +suite.test('removeViewField: deletes via DELETE /views/{id}/fields/{area}/{slot}', async () => { + // Field/widget removal is part of normal authoring (reversible by + // re-adding the same field_id), so there is no client-side gate — + // the only protection layer is the WP capability check on the + // server's permission_callback. mockHttpClient.setMockResponse( 'DELETE', '/views/9/fields/directory_list-title/slot1', @@ -346,20 +344,90 @@ suite.test('Validator: validateAgainstSchemas rejects unknown setting keys', asy ); }); -suite.test('Validator: validateAgainstSchemas skips numeric field ids (form fields)', async () => { - // Numeric ids can't be validated via field-type schema — they're - // form fields, not GravityView meta-fields. Validator should - // silently skip them rather than throwing. - let called = 0; - client.getFieldTypeSchema = async () => { - called++; - return { schema: [] }; +suite.test('Validator: validateAgainstSchemas resolves input_type for numeric ids and rejects unknown keys', async () => { + // Numeric field id "2" is form field of type=email. The validator + // looks up the input_type via listAvailableFields, then fetches + // the schema with field_type=field + input_type=email so the + // email-specific overlay (emailmailto, emailsubject, emailbody, + // emailencrypt) is in the valid-key set. A bogus setting like + // `made_up_email_setting` must be rejected. + client.listAvailableFields = async () => ({ + form_fields: [ + { id: '2', label: 'Email', input_type: 'email', type: 'email' }, + ], + }); + client.getFieldTypeSchema = async ({ field_type, input_type }) => { + TestAssert.equal(field_type, 'field'); + TestAssert.equal(input_type, 'email'); + return { + field_type: 'field', + schema: [ + { slug: 'show_label' }, + { slug: 'custom_label' }, + { slug: 'custom_class' }, + // Email-specific overlays: + { slug: 'emailmailto' }, + { slug: 'emailsubject' }, + { slug: 'emailbody' }, + { slug: 'emailencrypt' }, + ], + }; }; const v = new ViewValidator(client); + + // Email-specific setting allowed: await v.validateAgainstSchemas({ - fields: { 'directory_list-title': [{ field_id: '1' }, { field_id: '5.2' }] }, + id: 9999, + fields: { + 'directory_list-title': [{ field_id: '2', emailmailto: '1', emailsubject: 'Hi' }], + }, }); - TestAssert.equal(called, 0); + + // Bogus setting rejected — was the case Zack flagged in stress + // testing where validateAgainstSchemas:true silently accepted + // typoed keys on numeric form fields. + await TestAssert.throwsAsync( + () => + v.validateAgainstSchemas({ + id: 9999, + fields: { + 'directory_list-title': [{ field_id: '2', made_up_email_setting: 'nope' }], + }, + }), + 'unknown setting "made_up_email_setting"' + ); +}); + +suite.test('Validator: validateAgainstSchemas falls back to base schema when input_type lookup fails', async () => { + // No id supplied (e.g. gv_create_view before the View exists) — + // input_type lookup is skipped and the validator hits the field + // schema with no input_type. Catches the common typos against + // the base settings without false-positive-rejecting overlay + // settings the validator can't see. + client.getFieldTypeSchema = async ({ field_type, input_type }) => { + TestAssert.equal(field_type, 'field'); + TestAssert.equal(input_type, undefined); + return { + field_type: 'field', + schema: [ + { slug: 'show_label' }, + { slug: 'custom_label' }, + { slug: 'custom_class' }, + ], + }; + }; + const v = new ViewValidator(client); + + // Typo on a base-schema slug → rejected. + await TestAssert.throwsAsync( + () => + v.validateAgainstSchemas({ + fields: { + 'directory_list-title': [{ field_id: '1', custom_lable: 'typo' }], + }, + }), + 'unknown setting "custom_lable"' + ); }); suite.run(); diff --git a/src/view-operations/abilities-loader.js b/src/view-operations/abilities-loader.js new file mode 100644 index 0000000..d63411f --- /dev/null +++ b/src/view-operations/abilities-loader.js @@ -0,0 +1,184 @@ +/** + * Auto-generate MCP tool definitions from the live WordPress + * Abilities API catalog (`/wp-json/wp-abilities/v1/abilities`). + * + * Replaces the hand-maintained `viewToolDefinitions` array (and the + * corresponding `buildViewToolHandlers` switch) with a dynamic + * pipeline: + * + * 1. On MCP startup, fetch every ability registered under the + * `gk-gravityview/` namespace. + * 2. Transform each ability into a `{ name, description, inputSchema }` + * tuple the MCP runtime can list to clients. + * 3. Build a handler per ability that executes the ability through + * `/wp-abilities/v1/abilities/{name}/run` with the right HTTP + * method derived from the ability's annotations + * (`readonly` → GET, `destructive` → DELETE, otherwise POST). + * + * Naming convention: `gk-gravityview/list-layouts` → `gv_list_layouts`. + * Strips the namespace prefix and converts dashes to underscores. + * + * When the abilities catalog is unreachable (older WP without the + * Abilities API, plugin disabled, network blip), the caller falls + * back to the legacy hand-maintained tool definitions. + */ + +/** + * Convert a fully-qualified ability name to the MCP tool name our + * existing callers + docs already know. Idempotent. + * + * Examples: + * gk-gravityview/list-layouts → gv_list_layouts + * gk-gravityview/apply-view-config → gv_apply_view_config + * gk-gravityview/get-template-settings-schema → gv_get_template_settings_schema + */ +export function abilityNameToToolName(abilityName) { + if (typeof abilityName !== 'string' || !abilityName.includes('/')) { + return abilityName; + } + const [, slug] = abilityName.split('/'); + return 'gv_' + slug.replace(/-/g, '_'); +} + +/** + * Determine the HTTP method to use when executing an ability. + * Matches the Abilities API REST controller's contract: + * - readonly → GET + * - destructive + idempotent → DELETE + * - else POST + * + * @param {object} annotations Ability meta.annotations. + * @returns {'GET'|'POST'|'DELETE'} + */ +export function methodForAbility(annotations = {}) { + if (annotations.readonly) return 'GET'; + if (annotations.destructive) return 'DELETE'; + return 'POST'; +} + +/** + * Fetch the abilities catalog + build MCP tool definitions and + * handlers in a single pass. + * + * Throws when the abilities catalog endpoint is unreachable (the + * caller decides whether to fall back to legacy tool defs). + * + * @param {object} gvClient GravityViewClient instance — uses its + * authenticated httpClient. + * @param {string} [namespace='gk-gravityview'] Filter abilities by + * namespace prefix. + * @returns {Promise<{ definitions: object[], handlers: Record, count: number }>} + */ +export async function loadAbilitiesAsTools(gvClient, namespace = 'gk-gravityview') { + // gvClient.httpClient is namespaced to /gravityview/v1. The + // Abilities API lives at a sibling namespace (/wp-abilities/v1), + // so we override baseURL per-request to the WP root rather than + // creating a second axios instance (same auth + TLS config). + const { data } = await gvClient.httpClient.request({ + method: 'GET', + baseURL: gvClient.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + }); + if (!Array.isArray(data)) { + throw new Error('Unexpected Abilities API catalog shape — expected array.'); + } + + const ours = data.filter((a) => typeof a?.name === 'string' && a.name.startsWith(namespace + '/')); + + const definitions = []; + const handlers = {}; + + for (const ability of ours) { + const toolName = abilityNameToToolName(ability.name); + const annotations = ability?.meta?.annotations || {}; + + // MCP tool definition. inputSchema defaults to an open object + // when the ability has no declared input — callers can still + // pass keys; the server will validate per its own schema. + definitions.push({ + name: toolName, + description: ability.description || ability.label || ability.name, + inputSchema: ability.input_schema || { type: 'object', properties: {}, additionalProperties: true }, + }); + + // Closure captures the ability name + method so the dispatcher + // doesn't need to re-resolve them at call time. The server-side + // ability registry no longer exposes any "delete the whole View" + // ability — the most destructive surface left is removing a + // single field/widget/row, which is part of normal authoring + // (and reversible by re-adding). For status-level changes the + // caller uses gv_set_view_status with status='trash', gated by + // delete_post on the WP side. + const abilityName = ability.name; + const method = methodForAbility(annotations); + handlers[toolName] = async (params) => executeAbility(gvClient, abilityName, method, params || {}); + } + + return { definitions, handlers, count: ours.length }; +} + +/** + * Execute one ability via `/wp-abilities/v1/abilities/{name}/run`. + * + * Encoding rules per the Abilities API spec: + * - GET / DELETE: input rides on a `?input=` query arg + * - POST: input rides in the JSON body as `{input: ...}` + * + * Errors propagate verbatim from the server so the MCP runtime can + * surface them (the abilities-api's `WP_Error` codes — `ability_invalid_input`, + * `ability_invalid_permissions`, `rest_ability_invalid_method`, etc. — + * already carry enough detail for an agent to self-correct). + */ +/** + * Recursively expand a nested input object into bracket-notation + * query params: `input[key]=val`, `input[key][nested]=val`, etc. + * Mirrors how WordPress REST rebuilds an object from query strings, + * which is the wire shape readonly abilities expect. + */ +function walkInputToBracketedParams(value, key, out) { + if (value === undefined || value === null) return; + if (Array.isArray(value)) { + value.forEach((item, i) => walkInputToBracketedParams(item, `${key}[${i}]`, out)); + return; + } + if (typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + walkInputToBracketedParams(v, `${key}[${k}]`, out); + } + return; + } + out[key] = value; +} + +async function executeAbility(gvClient, abilityName, method, input) { + // Cross-namespace request — override baseURL away from + // /gravityview/v1 so the URL resolves to /wp-abilities/v1. + const baseURL = gvClient.baseUrl; + const url = `/wp-json/wp-abilities/v1/abilities/${abilityName}/run`; + + if (method === 'GET' || method === 'DELETE') { + // WordPress REST takes bracketed query params for object-typed + // args, NOT a JSON-stringified `?input=` value (the controller + // hands the raw string straight to the schema validator, which + // then complains "input is not of type object"). Recursively + // expand the input into `input[key][nested]=value` so WP + // rehydrates the nested object structure. + const config = { method, baseURL, url }; + if (input && Object.keys(input).length > 0) { + const params = {}; + walkInputToBracketedParams(input, 'input', params); + config.params = params; + } + const { data } = await gvClient.httpClient.request(config); + return data; + } + + // POST. The Abilities API wraps input under an `input` key in the body. + const { data } = await gvClient.httpClient.request({ + method, + baseURL, + url, + data: input && Object.keys(input).length > 0 ? { input } : {}, + }); + return data; +} diff --git a/src/view-operations/index.js b/src/view-operations/index.js index 548c1cf..254a419 100644 --- a/src/view-operations/index.js +++ b/src/view-operations/index.js @@ -42,10 +42,22 @@ export function createViewOperations(client) { export const viewToolDefinitions = [ // -------------------------------------------------------------- Discovery { - name: 'gv_list_templates', - description: 'List every installed GravityView layout template (id, slug, label, description, logo). Use to discover valid template_id values before creating or switching a View.', + name: 'gv_list_layouts', + description: 'List the installed GravityView layout engines (Layout Builder, DIY, Table, List, DataTables, Map, …) with id / label / description / logo / has_grid. Use to discover valid template_id values before creating or switching a View. Excludes legacy `preset_*` content presets and inactive add-on placeholders. `has_grid: true` means the layout drives placement via `POST /views/{id}/grid/_rows`; otherwise the layout exposes static areas (see gv_get_view_areas).', inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, }, + { + name: 'gv_get_template_settings_schema', + description: 'Per-template settings catalogue. Returns the full set of `template_settings` keys a layout supports — `page_size` / `sort_field` for any template, plus layout-specific settings (e.g. `map_zoom`, `map_type`, `map_layers` for the Maps layout; `datatables.responsive`, `datatables.rowgroup_field` for DataTables when the extension is installed). Discovered live via the `gk/gravityview/rest/template-settings/sources` filter so any add-on with its own silo meta key surfaces automatically. Dotted slugs (e.g. `datatables.responsive`) round-trip through PATCH /template-settings + apply.template_settings to the right meta key.', + inputSchema: { + type: 'object', + properties: { + template_id: { type: 'string', description: 'Layout template id (use gv_list_layouts to discover).' }, + ...COMPACT_ARG, + }, + required: ['template_id'], + }, + }, { name: 'gv_list_widgets', description: 'List every registered GravityView widget (id, label, description, icon, class). Use to discover valid widget ids before placing one with gv_add_view_widget. Sourced live from \\GV\\Widget::registered() so third-party widgets surface automatically.', @@ -73,12 +85,12 @@ export const viewToolDefinitions = [ }, { name: 'gv_get_field_type_schema', - description: 'Get the settings schema for a GravityView field type (e.g. "custom", "entry_link", "text"). No View id required — useful for AI agents authoring a fresh View.', + description: 'Get the settings schema for a GravityView field type (e.g. "custom", "entry_link", "text"). No View id required — useful for AI agents authoring a fresh View.\n\nLayout templates can add or remove settings on top of the core field schema (e.g. DIY adds a Container Tag picker, others may add their own settings). Pass `template_id` to fetch the overlay schema the chosen layout exposes; each schema item\'s `slug` is the setting key to use when building apply payloads. Run gv_list_layouts to discover which layouts are installed and inspect their settings_overlay descriptors.', inputSchema: { type: 'object', properties: { field_type: { type: 'string', description: 'Field type slug (e.g. "custom", "entry_link"). For form-bound fields (numeric ids), use gv_get_view_field_schemas instead.' }, - template_id: { type: 'string', description: 'Optional layout template id; affects which settings the schema includes. Defaults to default_list.' }, + template_id: { type: 'string', description: 'Optional layout template id; affects which settings the schema includes. Defaults to default_list. Different layouts overlay different setting sets — gv_list_layouts surfaces what each layout adds.' }, context: { type: 'string', enum: ['multiple', 'single', 'edit', 'search'], description: 'Render context. Defaults to multiple.' }, input_type: { type: 'string', description: 'Optional GF input type (textarea, select, …) for form-bound fields.' }, form_id: { type: 'integer', description: 'Optional form id for input-type detection.' }, @@ -111,14 +123,15 @@ export const viewToolDefinitions = [ }, { name: 'gv_render_view_field', - description: 'Render a single configured slot to HTML. Pass `settings` to preview an in-flight settings change WITHOUT persisting (server renders in-memory). Use to verify a format/conditional-logic change before committing.', + description: 'Render a single slot to HTML. Two staged-preview modes (server renders in-memory, persists nothing): pass `settings` to override an EXISTING saved slot, OR pass `staged_slot: { field_id, label?, ...settings }` together with a freshly-minted `slot` UID to render a brand-new (unsaved) slot the user just dragged into the layout. Without `staged_slot`, an unknown `slot` returns 404.', inputSchema: { type: 'object', properties: { id: VIEW_ID, area: AREA, slot: SLOT, - settings: { type: 'object', description: 'Optional staged settings overrides. Persists nothing.' }, + settings: { type: 'object', description: 'Optional staged settings overrides on a saved slot. Persists nothing.' }, + staged_slot: { type: 'object', description: 'Optional synthesized slot for unsaved-slot preview: `{ field_id, label?, ...settings }`. Use when the URL `slot` doesn\'t yet exist in storage (e.g. immediately after drag-in, before apply).' }, ...COMPACT_ARG, }, required: ['id', 'area', 'slot'], @@ -134,7 +147,7 @@ export const viewToolDefinitions = [ properties: { title: { type: 'string', description: 'View title (post title).' }, form_id: { type: 'integer', description: 'Source Gravity Forms form id. Use gf_list_forms to discover.' }, - template_id: { type: 'string', description: 'Layout template for the directory zone (Multiple Entries listing). Defaults to gravityview-layout-builder. Use gv_list_templates for the catalogue.' }, + template_id: { type: 'string', description: 'Layout template for the directory zone (Multiple Entries listing). Defaults to gravityview-layout-builder. Use gv_list_layouts for the catalogue.' }, template_ids: { type: 'object', description: 'Optional per-zone template overrides — { single?, edit? }. Multiple Entries (directory) and Single Entry can use different layouts (e.g. directory: gravityview-layout-builder, single: default_table). Single defaults to directory; edit follows directory unless explicitly set.', @@ -186,7 +199,7 @@ export const viewToolDefinitions = [ type: 'object', properties: { id: VIEW_ID, - template_id: { type: 'string', description: 'New template id. Use gv_list_templates to discover.' }, + template_id: { type: 'string', description: 'New template id. Use gv_list_layouts to discover.' }, zone: { type: 'string', enum: ['directory', 'single', 'edit'], description: 'Zone to switch. Defaults to directory.' }, policy: { type: 'string', enum: ['discard', 'keep'], description: 'discard (default) clears the zone\'s field+widget placements so they don\'t reference the old template\'s areas; keep preserves them at the risk of orphan placements.' }, ifMatch: IF_MATCH, @@ -269,7 +282,7 @@ export const viewToolDefinitions = [ }, { name: 'gv_remove_view_field', - description: 'Delete a single field slot. Requires GRAVITYVIEW_ALLOW_DELETE=true (or GRAVITY_FORMS_ALLOW_DELETE=true) in the MCP env to guard against accidents.', + description: 'Delete a single field slot. Reversible — call gv_add_view_field with the same field_id to restore.', inputSchema: { type: 'object', properties: { id: VIEW_ID, area: AREA, slot: SLOT, ifMatch: IF_MATCH, ...COMPACT_ARG }, @@ -304,7 +317,7 @@ export const viewToolDefinitions = [ }, { name: 'gv_remove_view_widget', - description: 'Delete a single widget slot. Requires GRAVITYVIEW_ALLOW_DELETE=true.', + description: 'Delete a single widget slot. Reversible — call gv_add_view_widget to restore.', inputSchema: { type: 'object', properties: { id: VIEW_ID, area: { type: 'string' }, slot: SLOT, ifMatch: IF_MATCH, ...COMPACT_ARG }, @@ -347,7 +360,7 @@ export const viewToolDefinitions = [ }, { name: 'gv_delete_grid_row', - description: 'Remove a grid row and every field/widget placed in any of its areas on the targeted surface. Requires GRAVITYVIEW_ALLOW_DELETE=true.', + description: 'Remove a grid row and every field/widget placed in any of its areas on the targeted surface.', inputSchema: { type: 'object', properties: { id: VIEW_ID, surface: { type: 'string', enum: ['fields', 'widgets'] }, row_uid: { type: 'string' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, @@ -398,7 +411,7 @@ export const viewToolDefinitions = [ }, { name: 'gv_remove_search_field', - description: 'Remove a search field slot from a search_bar widget\'s search_fields_section. Requires GRAVITYVIEW_ALLOW_DELETE=true.', + description: 'Remove a search field slot from a search_bar widget\'s search_fields_section.', inputSchema: { type: 'object', properties: { @@ -422,7 +435,8 @@ export const viewToolDefinitions = [ export function buildViewToolHandlers({ client, validator }) { return { // Discovery - gv_list_templates: () => client.listTemplates(), + gv_list_layouts: () => client.listLayouts(), + gv_get_template_settings_schema: (params) => client.getTemplateSettingsSchema(params), gv_list_widgets: () => client.listWidgets(), gv_list_grid_row_types: () => client.listGridRowTypes(), gv_list_widget_zones: () => client.listWidgetZones(), @@ -458,6 +472,7 @@ export function buildViewToolHandlers({ client, validator }) { validator.validateApplyPayload(params); if (params.validateAgainstSchemas) { await validator.validateAgainstSchemas({ + id: params.id, fields: params.fields || {}, widgets: params.widgets || {}, template_id: params.template_id, diff --git a/src/view-operations/view-validator.js b/src/view-operations/view-validator.js index 8d050e6..f289579 100644 --- a/src/view-operations/view-validator.js +++ b/src/view-operations/view-validator.js @@ -124,22 +124,41 @@ export class ViewValidator { * Pass `{ template_id, context }` so the schema fetch matches the * setting set the inspector actually persists for that combination. */ - async validateAgainstSchemas({ fields = {}, widgets = {}, template_id, context }) { + async validateAgainstSchemas({ id, fields = {}, widgets = {}, template_id, context }) { const allEntries = [ ...Object.values(fields).flatMap((arr) => arr.map((item) => ({ kind: 'field', item }))), ...Object.values(widgets).flatMap((arr) => arr.map((item) => ({ kind: 'widget', item }))), ]; + // Numeric field ids (form-bound fields) need the input type to + // resolve their full schema — `email` adds emailmailto/subject/ + // body, `address` adds show_map_link, `fileupload` adds + // link_to_file/image_*, etc. Look those up once per view via + // gv_list_available_fields so the validator catches typos on + // input-type-specific settings without false-positives. When no + // view id is supplied (e.g. gv_create_view before the View + // exists), the lookup is skipped and numeric ids fall through + // to a permissive base-only check. + const inputTypeByFieldId = id ? await this.getInputTypeMap(id) : new Map(); + for (const { kind, item } of allEntries) { // Widgets identify themselves through field_id too — InspectorRoute // detects when the type maps to a registered widget and returns // the widget's settings schema (e.g. search_bar → search_layout, // search_fields, …). Skipping schema validation for widgets would // miss the most common authoring mistake (typoed setting key). - const typeSlug = guessFieldType(item.field_id); + const fieldId = String(item.field_id ?? '').trim(); + const isNum = /^\d+(\.\d+)?$/.test(fieldId); + const typeSlug = isNum ? 'field' : fieldId; if (!typeSlug) continue; - const schema = await this.getSchema(typeSlug, template_id, context); + // For numeric ids, the actual GF input type drives which + // overlay applies. Fall back to no input_type when we can't + // look it up — yields the BASE schema only, which still + // catches typos like `custom_lable` and `cusotm_class`. + const inputType = isNum ? (inputTypeByFieldId.get(fieldId) || undefined) : undefined; + + const schema = await this.getSchema(typeSlug, template_id, context, inputType); if (!schema || !Array.isArray(schema.schema)) continue; const validKeys = new Set(schema.schema.map((entry) => entry.slug)); @@ -161,14 +180,45 @@ export class ViewValidator { if (!validKeys.has(key)) { const samples = [...validKeys].slice(0, 12).join(', '); const more = validKeys.size > 12 ? ', …' : ''; + const inputHint = inputType ? ` (input_type=${inputType})` : ''; throw new Error( - `${kind} "${item.field_id}" has unknown setting "${key}". Schema for ${schema.kind || 'type'} "${typeSlug}" lists: ${samples}${more}` + `${kind} "${item.field_id}" has unknown setting "${key}". Schema for ${schema.kind || 'type'} "${typeSlug}"${inputHint} lists: ${samples}${more}` ); } } } } + /** + * Build a map of `field_id → input_type` for all GF form fields + * available to this View. Cached per view id so repeat calls in + * the same MCP session don't refetch. + * + * @param {number} viewId + * @returns {Promise>} + */ + async getInputTypeMap(viewId) { + if (!this._inputTypeMaps) this._inputTypeMaps = new Map(); + if (this._inputTypeMaps.has(viewId)) return this._inputTypeMaps.get(viewId); + + let map = new Map(); + try { + const data = await this.client.listAvailableFields({ id: viewId }); + const formFields = Array.isArray(data?.form_fields) ? data.form_fields : []; + for (const field of formFields) { + const fid = String(field.id ?? '').trim(); + const it = String(field.input_type ?? field.type ?? '').trim(); + if (fid && it) map.set(fid, it); + } + } catch (err) { + // Available-fields fetch failed (network, perms, missing form) + // — degrade to permissive (base-only) schema validation. The + // server still validates on apply. + } + this._inputTypeMaps.set(viewId, map); + return map; + } + /** * When the payload places fields into Layout Builder area keys * (compound `{prefix}-{areaid}::{type}::{row_uid}` form), confirm @@ -180,8 +230,11 @@ export class ViewValidator { * No-op when none of the area keys look like Layout Builder keys. */ async validateLayoutBuilderAreas({ id, fields = {}, widgets = {} }) { - const allAreas = Object.keys({ ...fields, ...widgets }); - const lbAreas = allAreas.filter((key) => key.includes('::')); + // gv_get_view_areas returns FIELD-zone areas only (directory / + // single / edit). Widget area keys live on a separate surface + // (header_* / footer_*) and are validated server-side against the + // widget tree, so skip them here to avoid false-positive rejects. + const lbAreas = Object.keys(fields).filter((key) => key.includes('::')); if (!id || lbAreas.length === 0) return; let known; @@ -214,11 +267,16 @@ export class ViewValidator { } } - async getSchema(fieldType, template_id, context) { - const key = `${fieldType}|${template_id || ''}|${context || ''}`; + async getSchema(fieldType, template_id, context, input_type) { + const key = `${fieldType}|${template_id || ''}|${context || ''}|${input_type || ''}`; if (this.schemaCache.has(key)) return this.schemaCache.get(key); try { - const data = await this.client.getFieldTypeSchema({ field_type: fieldType, template_id, context }); + const data = await this.client.getFieldTypeSchema({ + field_type: fieldType, + template_id, + context, + input_type, + }); this.schemaCache.set(key, data); return data; } catch (error) { @@ -231,23 +289,4 @@ export class ViewValidator { } } -/** - * Best-effort field-type inference from a `field_id`. The inspector - * REST API uses the same identifier for both numeric form fields - * (e.g. "1", "5.2") and meta-fields (e.g. "custom", "entry_link", - * "edit_link"). Numeric ids are form fields and could be any type; - * non-numeric ids ARE the type slug. For numeric ids we return null - * because validating them needs the form schema, not the field-type - * schema (different API). - */ -function guessFieldType(fieldId) { - if (fieldId === undefined || fieldId === null) return null; - const str = String(fieldId).trim(); - if (str === '') return null; - // Numeric (incl. composite like "5.2") — we can't determine type - // from the id alone. Skip schema-aware validation for these. - if (/^\d+(\.\d+)?$/.test(str)) return null; - return str; -} - export default ViewValidator; From b066eb72113a44b88d4cb6e0746a3ced17f97524 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 14 May 2026 18:52:00 -0400 Subject: [PATCH 03/36] test(gv): live coverage for the post-Gemini-review enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 9 new live stress tests covering the new abilities + behaviour shipped in the GravityView feature/3.0-config-editor branch: * gv_list_views — substring search lands a freshly-minted View; form_id filter narrows correctly; pagination metadata round-trips. * gv_get_view_config `include` projection — narrows the response to just the requested top-level keys; view_id always present. * Dry-run on gv_apply_view_config — meta unchanged after dry; flag + would_apply stamped on the response. * Dry-run on gv_patch_view_field — second write at dry_run=true does not overwrite the value persisted by the prior real write. * Dry-run on gv_add_view_field — slot count unchanged after dry-run. * Catalog: every gk-gravityview ability advertises a non-empty next_steps array, each entry is { ability:gk-gravityview/*, when:* }. * Discovery bridge: list-layouts has_grid description names list-grid-row-types AND list-view-areas as the next steps. * Field presets: default catalog is empty (filter-driven, core ships none). * Field presets: apply-field-preset returns 404 for an unknown preset id. 123/123 passing. --- src/tests/views-stress.test.js | 221 +++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/src/tests/views-stress.test.js b/src/tests/views-stress.test.js index a15828e..2cf9c66 100644 --- a/src/tests/views-stress.test.js +++ b/src/tests/views-stress.test.js @@ -2707,4 +2707,225 @@ suite.test('New ability: gv_set_view_status rejects an invalid status enum value TestAssert.equal(status, 400, 'invalid status → 400'); }); +// ==================================================================== +// Coverage for the post-Gemini-review enhancements +// ==================================================================== + +suite.test('New ability: gv_list_views enumerates with status / form_id / search filters', async () => { + if (suite.skip) return; + const seedTitle = `[stress] list-views needle ${Date.now()}`; + const view = await h.gv_create_view({ + title: seedTitle, + form_id: Number(formId), + template_id: 'gravityview-layout-builder', + status: 'draft', + }); + mintedViewIds.push(view.view_id); + + // Substring search picks up the freshly-created View. + const found = await h.gv_list_views({ search: 'list-views needle', per_page: 10 }); + TestAssert.isTrue(Array.isArray(found.views), 'returns views array'); + TestAssert.isTrue(found.total >= 1, 'total reflects matching count'); + const match = found.views.find((v) => v.view_id === view.view_id); + TestAssert.isTrue(!!match, 'newly-minted View appears in search results'); + TestAssert.equal(match.form_id, Number(formId), 'form_id reflected'); + TestAssert.equal(match.status, 'draft', 'status reflected'); + + // form_id filter narrows to that form (every result must match). + const byForm = await h.gv_list_views({ form_id: Number(formId), per_page: 5 }); + for (const v of byForm.views) { + TestAssert.equal(v.form_id, Number(formId), 'every row matches form_id filter'); + } + + // Pagination metadata. + const paged = await h.gv_list_views({ per_page: 2, page: 1 }); + TestAssert.equal(paged.per_page, 2, 'per_page echoed'); + TestAssert.equal(paged.page, 1, 'page echoed'); + TestAssert.isTrue(paged.total_pages >= 1, 'total_pages computed'); +}); + +suite.test('Projection: gv_get_view_config include narrows the response shape', async () => { + if (suite.skip) return; + const viewId = await mintView('projection'); + + const full = await h.gv_get_view_config({ id: viewId, compact: false }); + TestAssert.isTrue('template_id' in full, 'full has template_id'); + TestAssert.isTrue('fields' in full, 'full has fields'); + TestAssert.isTrue('widgets' in full, 'full has widgets'); + + const slim = await h.gv_get_view_config({ + id: viewId, + include: ['template_settings', 'form_id'], + compact: false, + }); + TestAssert.isTrue('view_id' in slim, 'view_id always present (projection invariant)'); + TestAssert.isTrue('form_id' in slim, 'requested form_id present'); + TestAssert.isTrue('template_settings' in slim, 'requested template_settings present'); + TestAssert.isTrue(!('fields' in slim), 'unrequested fields stripped'); + TestAssert.isTrue(!('widgets' in slim), 'unrequested widgets stripped'); + TestAssert.isTrue(!('template_id' in slim), 'unrequested template_id stripped'); +}); + +suite.test('Dry-run: gv_apply_view_config dry_run=true does NOT persist + flags response', async () => { + if (suite.skip) return; + const viewId = await mintView('dry-run apply'); + + // Real bulk write to set a baseline. Each apply bumps the version, + // so re-fetch + re-quote between writes (the test harness only + // caches the version returned by the LAST call; the unit-test + // happens to interleave reads + writes that defeat that). + await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + template_settings: { page_size: 25 }, + }); + const before = await h.gv_get_view_config({ id: viewId, include: ['template_settings'] }); + TestAssert.equal(before.template_settings.page_size, 25, 'baseline persisted'); + + const dry = await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + template_settings: { page_size: 999 }, + dry_run: true, + }); + TestAssert.equal(dry.dry_run, true, 'response flagged dry_run'); + TestAssert.equal(dry.would_apply, true, 'response flagged would_apply'); + + const after = await h.gv_get_view_config({ id: viewId, include: ['template_settings'] }); + TestAssert.equal(after.template_settings.page_size, 25, 'meta unchanged after dry-run'); +}); + +suite.test('Dry-run: gv_patch_view_field dry_run=true validates without persisting', async () => { + if (suite.skip) return; + const viewId = await mintView('dry-run patch-field'); + await h.gv_add_grid_row({ + id: viewId, + surface: 'fields', + row_uid: 'r1', + type: '100', + template_ids: ['default_table'], + }); + const added = await h.gv_add_view_field({ + id: viewId, + area: 'directory_table-columns', + field_id: 'custom', + label: 'Original', + }); + await h.gv_patch_view_field({ + id: viewId, + area: 'directory_table-columns', + slot: added.slot, + settings: { custom_label: 'Real Label' }, + }); + + const dryPatch = await h.gv_patch_view_field({ + id: viewId, + area: 'directory_table-columns', + slot: added.slot, + settings: { custom_label: 'Dry Label' }, + dry_run: true, + }); + TestAssert.equal(dryPatch.dry_run, true); + + const after = await h.gv_get_view_config({ id: viewId }); + const stored = after.fields['directory_table-columns']?.[added.slot]?.custom_label; + TestAssert.equal(stored, 'Real Label', 'meta still holds the real-write value, not the dry-run value'); +}); + +suite.test('Dry-run: gv_add_view_field dry_run=true returns shape but does NOT add a slot', async () => { + if (suite.skip) return; + const viewId = await mintView('dry-run add-field'); + await h.gv_add_grid_row({ + id: viewId, + surface: 'fields', + row_uid: 'r1', + type: '100', + template_ids: ['default_table'], + }); + + const beforeCount = Object.keys( + (await h.gv_get_view_config({ id: viewId })).fields?.['directory_table-columns'] ?? {}, + ).length; + + const dry = await h.gv_add_view_field({ + id: viewId, + area: 'directory_table-columns', + field_id: 'custom', + label: 'Hypothetical', + dry_run: true, + }); + TestAssert.equal(dry.dry_run, true); + + const afterCount = Object.keys( + (await h.gv_get_view_config({ id: viewId })).fields?.['directory_table-columns'] ?? {}, + ).length; + TestAssert.equal(afterCount, beforeCount, 'slot count unchanged after dry-run add'); +}); + +suite.test('Catalog: every gk-gravityview ability advertises a next_steps annotation', async () => { + if (suite.skip) return; + const { data: catalog } = await gvClient.httpClient.request({ + method: 'GET', + baseURL: gvClient.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + }); + const ours = catalog.filter((a) => typeof a?.name === 'string' && a.name.startsWith('gk-gravityview/')); + TestAssert.isTrue(ours.length > 0, 'at least one gk-gravityview ability'); + for (const ab of ours) { + const ns = ab?.meta?.annotations?.next_steps; + TestAssert.isTrue(Array.isArray(ns), `${ab.name} has next_steps array`); + for (const step of ns) { + TestAssert.isTrue( + typeof step?.ability === 'string' && step.ability.startsWith('gk-gravityview/'), + `${ab.name} next-step references gk-gravityview ability`, + ); + TestAssert.isTrue(typeof step?.when === 'string' && step.when.length > 0, `${ab.name} next-step has when text`); + } + } +}); + +suite.test('Discovery bridge: list-layouts has_grid description points at list-grid-row-types', async () => { + if (suite.skip) return; + const { data: catalog } = await gvClient.httpClient.request({ + method: 'GET', + baseURL: gvClient.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + }); + const listLayouts = catalog.find((a) => a.name === 'gk-gravityview/list-layouts'); + const hasGridDesc = + listLayouts?.output_schema?.properties?.layouts?.items?.properties?.has_grid?.description ?? ''; + TestAssert.isTrue( + hasGridDesc.includes('list-grid-row-types'), + 'has_grid description bridges to list-grid-row-types (the discovery step)', + ); + TestAssert.isTrue( + hasGridDesc.includes('list-view-areas'), + 'has_grid description bridges to list-view-areas for static layouts', + ); +}); + +suite.test('Field presets: default catalog is empty (filter-driven, core ships none)', async () => { + if (suite.skip) return; + const r = await h.gv_list_field_presets(); + TestAssert.equal(r.count, 0, 'no core-shipped presets'); + TestAssert.isTrue(Array.isArray(r.presets), 'presets is an array'); + TestAssert.equal(r.presets.length, 0); +}); + +suite.test('Field presets: apply-field-preset rejects an unknown preset id with 404', async () => { + if (suite.skip) return; + const viewId = await mintView('preset 404'); + let status = null; + try { + await h.gv_apply_field_preset({ + id: viewId, + preset_id: 'definitely-not-registered', + area: 'directory_list-title', + }); + } catch (err) { + status = err?.response?.status ?? null; + } + TestAssert.equal(status, 404, 'unknown preset id → 404'); +}); + suite.run(); From 6a6a8e57235324366cd82d06be78b650eebe3915 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 15 May 2026 01:00:38 -0400 Subject: [PATCH 04/36] feat(loader): surface gk-multiple-forms/* abilities + 17 live MFV stress tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loader change: * abilities-loader.js now accepts an array of namespace prefixes (default ['gk-gravityview', 'gk-multiple-forms']) instead of a single string. Both namespaces collapse into the `gv_*` MCP tool prefix because they're conceptually one product family from the agent's point of view. Backward compatible — string args still work via the array-or-string normalisation at the top of loadAbilitiesAsTools. Tests: Basic coverage (12 tests): * catalog exposes gv_list_joins / gv_apply_joins / gv_list_joinable_fields * list-joins on a no-joins View returns empty + count=0 * list-joinable-fields enumerates form fields + entry-property aliases * apply-joins dry_run flags response + does NOT persist * apply-joins persists + list-joins inflates form/field labels * apply-joins is replace-not-merge (3 → 1 → 0 cycle) * apply-joins rejects malformed rows with HTTP 400 (no partial write) * apply-view-config writes joins via the cross-plugin filter * get-view-config include=[joins] projection narrows shape * list-views match_joined surfaces Views joining a form * list-available-fields includes joined_form_fields tagged with form_id * every gk-multiple-forms/* ability advertises a next_steps annotation Deep authoring coverage — exercises mixed-form field placement in real View areas (5 tests): * field slots from primary AND joined forms coexist in one area (with field-id collision: both forms have id=1) * 3-form join + fields from each form land in distinct areas * apply-view-config bulk: joins + fields from both forms in one call * dry_run on mixed-form bulk apply does NOT persist any slot * apply-joins clears + replaces, list-joins reflects each step Total: 140 passed, 0 failed (was 123 baseline → +17 MFV tests). All run live against dev.test admin/admin. --- src/tests/views-stress.test.js | 525 ++++++++++++++++++++++++ src/view-operations/abilities-loader.js | 25 +- 2 files changed, 546 insertions(+), 4 deletions(-) diff --git a/src/tests/views-stress.test.js b/src/tests/views-stress.test.js index 2cf9c66..ba5254f 100644 --- a/src/tests/views-stress.test.js +++ b/src/tests/views-stress.test.js @@ -2928,4 +2928,529 @@ suite.test('Field presets: apply-field-preset rejects an unknown preset id with TestAssert.equal(status, 404, 'unknown preset id → 404'); }); +// ==================================================================== +// Multiple Forms add-on stress (gk-multiple-forms/* abilities + +// cross-plugin filters on get/apply-view-config + list-views). +// +// All tests skip themselves when MFV isn't loaded — that's surfaced +// via the absence of `gv_list_joins` from the catalog. On dev.test +// MFV is active, so they run for real. +// ==================================================================== + +const mfvSkip = () => suite.skip || typeof h?.gv_list_joins !== 'function'; + +/** Mint a throwaway secondary GF form for join-target tests. */ +async function mintSecondaryForm(label) { + const created = await gfClient.createForm({ + title: `[stress mfv ${label}] ${Date.now()}`, + fields: [ + { id: 1, type: 'text', label: 'Customer Ref' }, + { id: 2, type: 'text', label: 'Email' }, + { id: 3, type: 'number', label: 'Amount' }, + ], + }); + // createForm returns `{ form: { id }, edit_url, entries_url }` — + // the form id lives on `.form.id`, not on the top-level result. + const id = Number(created?.form?.id ?? 0); + if (!id) { + throw new Error('mintSecondaryForm: created form id missing from createForm response'); + } + return id; +} + +suite.test('MFV: catalog exposes the three gk-multiple-forms/* abilities', async () => { + if (mfvSkip()) return; + TestAssert.isTrue(typeof h.gv_list_joins === 'function', 'gv_list_joins handler present'); + TestAssert.isTrue(typeof h.gv_apply_joins === 'function', 'gv_apply_joins handler present'); + TestAssert.isTrue(typeof h.gv_list_joinable_fields === 'function', 'gv_list_joinable_fields handler present'); +}); + +suite.test('MFV: list-joins on a no-joins View → empty + count=0', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv list empty'); + const r = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(r.count, 0, 'no joins on a fresh View'); + TestAssert.isTrue(Array.isArray(r.joins), 'joins is an array'); +}); + +suite.test('MFV: list-joinable-fields enumerates form fields + entry-property aliases', async () => { + if (mfvSkip()) return; + const formId = await mintSecondaryForm('joinable'); + const r = await h.gv_list_joinable_fields({ form_id: formId }); + TestAssert.isTrue(r.fields.length >= 3, 'at least 3 numeric fields + aliases'); + const ids = r.fields.map((f) => f.id); + for (const expected of ['1', '2', '3', 'entry_id', 'created_by']) { + TestAssert.isTrue(ids.includes(expected), `expected ${expected} in joinable fields`); + } + // Cleanup + await gfClient.deleteForm(formId).catch(() => {}); +}); + +suite.test('MFV: apply-joins dry_run → flags response + does NOT persist', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv dry-run apply-joins'); + const joinedFormId = await mintSecondaryForm('dry'); + const dry = await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + dry_run: true, + }); + TestAssert.equal(dry.dry_run, true, 'dry_run flag stamped'); + TestAssert.equal(dry.would_apply, true, 'would_apply flag stamped'); + TestAssert.equal(dry.count, 1, 'count reports the validated row count'); + + const after = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(after.count, 0, 'meta unchanged after dry-run'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: apply-joins persists + list-joins inflates form/field labels', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv apply real'); + const joinedFormId = await mintSecondaryForm('real'); + const r = await h.gv_apply_joins({ + id: viewId, + joins: [ + [Number(formId), '1', joinedFormId, '1'], + [Number(formId), 'entry_id', joinedFormId, '3'], + ], + }); + TestAssert.equal(r.count, 2); + + const list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 2, 'two joins surfaced'); + TestAssert.isTrue(list.joins[0].details.base_form_label.length > 0, 'base form label inflated'); + TestAssert.isTrue(list.joins[0].details.base_form_active, 'base form active'); + TestAssert.isTrue(list.joins[0].details.join_form_active, 'join form active'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: apply-joins replaces (not merges) — 3 → 1 → 0', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv replace'); + const joinedFormId = await mintSecondaryForm('replace'); + + // Set 3 + let r = await h.gv_apply_joins({ + id: viewId, + joins: [ + [Number(formId), '1', joinedFormId, '1'], + [Number(formId), '2', joinedFormId, '2'], + [Number(formId), 'entry_id', joinedFormId, '3'], + ], + }); + TestAssert.equal(r.count, 3); + + // Replace with 1 + r = await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + }); + TestAssert.equal(r.count, 1, 'apply-joins is replace-not-merge'); + let list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 1); + + // Clear with empty + r = await h.gv_apply_joins({ id: viewId, joins: [] }); + TestAssert.equal(r.count, 0); + list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 0, 'empty array clears all joins'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: apply-joins rejects malformed rows with 400', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv invalid rows'); + let status = null; + try { + await h.gv_apply_joins({ + id: viewId, + joins: [ + [Number(formId), '1', 999999, '1'], + ['not-numeric', '1', 999999, '1'], // invalid base_form_id type + ], + }); + } catch (err) { + status = err?.response?.status ?? null; + } + TestAssert.equal(status, 400, 'malformed row → 400'); + + // Verify the View still has no joins (atomic rollback on error). + const list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 0, 'no partial write on validation error'); +}); + +suite.test('MFV: apply-view-config writes joins via the cross-plugin filter', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv cross-plugin'); + const joinedFormId = await mintSecondaryForm('crossplugin'); + + await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + joins: [ + [Number(formId), '1', joinedFormId, '1'], + [Number(formId), 'entry_id', joinedFormId, '3'], + ], + }); + + const cfg = await h.gv_get_view_config({ id: viewId, compact: false }); + TestAssert.isTrue(Array.isArray(cfg.joins), 'get-view-config exposes joins'); + TestAssert.equal(cfg.joins.length, 2, 'apply-view-config persisted both joins'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: get-view-config include=[joins] projection narrows shape', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv projection'); + const joinedFormId = await mintSecondaryForm('proj'); + await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + }); + + const slim = await h.gv_get_view_config({ + id: viewId, + include: ['joins'], + compact: false, + }); + TestAssert.isTrue('joins' in slim, 'projection kept joins'); + TestAssert.equal(slim.joins.length, 1); + TestAssert.isTrue(!('fields' in slim), 'projection stripped fields'); + TestAssert.isTrue(!('widgets' in slim), 'projection stripped widgets'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: list-views match_joined surfaces Views joining a form (not just primary)', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv list-views match'); + const joinedFormId = await mintSecondaryForm('listmatch'); + await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + }); + + // Search for Views connected to the JOINED form (not primary). + const matched = await h.gv_list_views({ form_id: joinedFormId, match_joined: true }); + TestAssert.isTrue( + matched.views.some((v) => v.view_id === viewId), + 'View surfaces under match_joined when its form is only joined', + ); + + // Without match_joined, the joined-only View must NOT show up. + const unmatched = await h.gv_list_views({ form_id: joinedFormId }); + TestAssert.isTrue( + !unmatched.views.some((v) => v.view_id === viewId), + 'plain form_id filter excludes joined-only Views', + ); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: list-available-fields includes joined_form_fields tagged with form_id', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv available-fields'); + const joinedFormId = await mintSecondaryForm('availfields'); + await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + }); + + const r = await h.gv_list_available_fields({ id: viewId, zone: 'directory' }); + TestAssert.isTrue(Array.isArray(r.joined_form_fields), 'joined_form_fields is an array'); + TestAssert.isTrue(r.joined_form_fields.length > 0, 'at least one joined-form field returned'); + for (const f of r.joined_form_fields) { + TestAssert.equal(f.form_id, joinedFormId, 'every joined field tagged with the joined form_id'); + } + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: every gk-multiple-forms/* ability advertises a next_steps annotation', async () => { + if (mfvSkip()) return; + const { data: catalog } = await gvClient.httpClient.request({ + method: 'GET', + baseURL: gvClient.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + }); + const ours = catalog.filter((a) => typeof a?.name === 'string' && a.name.startsWith('gk-multiple-forms/')); + TestAssert.isTrue(ours.length >= 3, 'at least three MFV abilities registered'); + for (const ab of ours) { + const ns = ab?.meta?.annotations?.next_steps; + TestAssert.isTrue(Array.isArray(ns) && ns.length > 0, `${ab.name} advertises next_steps`); + } +}); + +// -------------------------------------------------------------------- +// DEEP Multi-Form authoring stress — mixes fields from both the primary +// and joined forms into the same View areas, the way a real +// multi-form authoring flow does. Verifies the field tree records +// each slot's source `form_id` correctly so renderers hydrate against +// the right form / field collision space. +// -------------------------------------------------------------------- + +suite.test('MFV deep: field slots from primary AND joined forms coexist in one area', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv mixed fields'); + + // Mint the joined form with field IDs that would COLLIDE with the + // primary if disambiguation were broken: both forms have id=1 + id=2. + const joinedFormId = await mintSecondaryForm('mixed-fields'); + + // Wire the join so the View can pull from both forms. + await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + }); + + // Layout Builder needs a row before fields can land. + await h.gv_add_grid_row({ + id: viewId, + surface: 'fields', + row_uid: 'mixed_row', + type: '100', + template_ids: ['gravityview-layout-builder'], + }); + + // Discover fields available from both forms. + const avail = await h.gv_list_available_fields({ id: viewId, zone: 'directory' }); + TestAssert.isTrue(Array.isArray(avail.form_fields), 'primary form_fields surfaced'); + TestAssert.isTrue(Array.isArray(avail.joined_form_fields), 'joined_form_fields surfaced'); + TestAssert.isTrue(avail.form_fields.length > 0, 'primary form has fields'); + TestAssert.isTrue(avail.joined_form_fields.length > 0, 'joined form has fields'); + + // Every joined field must carry the joined form_id tag — not the primary. + for (const f of avail.joined_form_fields) { + TestAssert.equal(f.form_id, joinedFormId, 'joined field tagged with joined form_id'); + } + + // Pick one numeric field id from each form. Using `1` from both + // intentionally — that's the collision case real Multi-Form + // configurations hit. + const primaryFieldId = avail.form_fields[0]?.id; + const joinedFieldId = avail.joined_form_fields[0]?.id; + TestAssert.isTrue(!!primaryFieldId, 'have a primary field id'); + TestAssert.isTrue(!!joinedFieldId, 'have a joined field id'); + + // Add a slot from the PRIMARY form into the View's grid area. + const primarySlot = await h.gv_add_view_field({ + id: viewId, + area: 'directory_mixed_row-1', + field_id: primaryFieldId, + label: 'Primary Field', + form_id: Number(formId), + }); + TestAssert.isTrue(!!primarySlot.slot, 'primary slot created'); + + // Add a slot from the JOINED form into the SAME area. + const joinedSlot = await h.gv_add_view_field({ + id: viewId, + area: 'directory_mixed_row-1', + field_id: joinedFieldId, + label: 'Joined Field', + form_id: joinedFormId, + }); + TestAssert.isTrue(!!joinedSlot.slot, 'joined slot created'); + TestAssert.isTrue(joinedSlot.slot !== primarySlot.slot, 'unique slot UID despite same field_id'); + + // Verify both slots are persisted in the field tree under the same area. + const cfg = await h.gv_get_view_config({ id: viewId, compact: false }); + const area = cfg.fields?.['directory_mixed_row-1']; + TestAssert.isTrue(typeof area === 'object' && area !== null, 'area exists in field tree'); + TestAssert.isTrue(primarySlot.slot in area, 'primary slot in tree'); + TestAssert.isTrue(joinedSlot.slot in area, 'joined slot in tree'); + + // The View's joins are still intact after the field placements. + const cfgJoins = cfg.joins; + TestAssert.isTrue(Array.isArray(cfgJoins) && cfgJoins.length === 1, 'join survived field placements'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV deep: 3-form join + fields from each form land in distinct areas', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv 3-form'); + const orderForm = await mintSecondaryForm('orders'); + const addressForm = await mintSecondaryForm('addresses'); + + // Triple-form join: primary ← orders ← addresses (cascading). + await h.gv_apply_joins({ + id: viewId, + joins: [ + [Number(formId), '1', orderForm, '1'], + [orderForm, '2', addressForm, '2'], + ], + }); + + // List joins includes both rows + inflates labels for all three forms. + const list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 2); + const labels = list.joins.map((j) => `${j.details.base_form_label}/${j.details.join_form_label}`); + TestAssert.isTrue(labels.length === 2, 'two label pairs'); + TestAssert.isTrue(labels.every((l) => l.includes('/')), 'every join surfaces both form labels'); + + // available-fields now spans all three forms. + const avail = await h.gv_list_available_fields({ id: viewId }); + const joinedFormIds = new Set(avail.joined_form_fields.map((f) => f.form_id)); + TestAssert.isTrue(joinedFormIds.has(orderForm), 'orders form_id present in joined_form_fields'); + TestAssert.isTrue(joinedFormIds.has(addressForm), 'addresses form_id present in joined_form_fields'); + + // Place one field from each form into 3 different rows so the View + // is genuinely cross-form authored. + for (const rowUid of ['r_orders', 'r_addr']) { + await h.gv_add_grid_row({ + id: viewId, + surface: 'fields', + row_uid: rowUid, + type: '100', + template_ids: ['gravityview-layout-builder'], + }); + } + + await h.gv_add_view_field({ + id: viewId, + area: 'directory_r_orders-1', + field_id: avail.joined_form_fields.find((f) => f.form_id === orderForm)?.id, + label: 'Order Field', + form_id: orderForm, + }); + await h.gv_add_view_field({ + id: viewId, + area: 'directory_r_addr-1', + field_id: avail.joined_form_fields.find((f) => f.form_id === addressForm)?.id, + label: 'Address Field', + form_id: addressForm, + }); + + const cfg = await h.gv_get_view_config({ id: viewId, compact: false }); + TestAssert.isTrue(Object.keys(cfg.fields?.['directory_r_orders-1'] ?? {}).length === 1, 'orders row has 1 slot'); + TestAssert.isTrue(Object.keys(cfg.fields?.['directory_r_addr-1'] ?? {}).length === 1, 'address row has 1 slot'); + + await gfClient.deleteForm(orderForm).catch(() => {}); + await gfClient.deleteForm(addressForm).catch(() => {}); +}); + +suite.test('MFV deep: apply-view-config bulk — joins + fields from both forms in one call', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv bulk mixed'); + const joinedFormId = await mintSecondaryForm('bulk'); + + // First materialise a row to host the slots. + await h.gv_add_grid_row({ + id: viewId, + surface: 'fields', + row_uid: 'bulk_row', + type: '100', + template_ids: ['gravityview-layout-builder'], + }); + + // Bulk write everything in one shot: joins + fields tree spanning both forms. + await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + joins: [[Number(formId), '1', joinedFormId, '1']], + fields: { + 'directory_bulk_row-1': [ + { field_id: '1', label: 'From Primary', form_id: Number(formId) }, + { field_id: '2', label: 'Email Primary', form_id: Number(formId) }, + { field_id: '1', label: 'From Joined', form_id: joinedFormId }, + { field_id: '3', label: 'Amount Joined', form_id: joinedFormId }, + ], + }, + }); + + const cfg = await h.gv_get_view_config({ id: viewId, compact: false }); + TestAssert.isTrue(Array.isArray(cfg.joins) && cfg.joins.length === 1, 'bulk wrote joins'); + const slots = cfg.fields?.['directory_bulk_row-1'] ?? {}; + TestAssert.equal(Object.keys(slots).length, 4, 'four slots in bulk_row-1'); + + // Verify that the View knows which slots came from which form. + const formIds = Object.values(slots).map((s) => Number(s.form_id ?? 0)); + const primaryCount = formIds.filter((id) => id === Number(formId)).length; + const joinedCount = formIds.filter((id) => id === joinedFormId).length; + TestAssert.isTrue(primaryCount >= 2, 'at least 2 slots from primary form'); + TestAssert.isTrue(joinedCount >= 2, 'at least 2 slots from joined form'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV deep: dry_run on mixed-form bulk apply does NOT persist any slot', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv dry mixed'); + const joinedFormId = await mintSecondaryForm('dry-mixed'); + + await h.gv_add_grid_row({ + id: viewId, + surface: 'fields', + row_uid: 'dry_row', + type: '100', + template_ids: ['gravityview-layout-builder'], + }); + + const dry = await h.gv_apply_view_config({ + id: viewId, + mode: 'merge', + dry_run: true, + joins: [[Number(formId), '1', joinedFormId, '1']], + fields: { + 'directory_dry_row-1': [ + { field_id: '1', label: 'Primary', form_id: Number(formId) }, + { field_id: '1', label: 'Joined', form_id: joinedFormId }, + ], + }, + }); + TestAssert.equal(dry.dry_run, true); + + const cfg = await h.gv_get_view_config({ id: viewId, compact: false }); + TestAssert.isTrue(!cfg.joins || cfg.joins.length === 0, 'no joins persisted on dry-run'); + const slots = cfg.fields?.['directory_dry_row-1'] ?? {}; + TestAssert.equal(Object.keys(slots).length, 0, 'no slots persisted on dry-run'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV deep: apply-joins clears + replaces, list-joins reflects each step', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv replace cycle'); + const f1 = await mintSecondaryForm('repl1'); + const f2 = await mintSecondaryForm('repl2'); + + // Round 1: 2 joins + await h.gv_apply_joins({ + id: viewId, + joins: [ + [Number(formId), '1', f1, '1'], + [Number(formId), '2', f2, '2'], + ], + }); + let list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 2); + + // Round 2: replace with a single join to f2 only + await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), 'entry_id', f2, '3']], + }); + list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 1); + TestAssert.equal(list.joins[0].join_form_id, f2); + + // Round 3: clear + await h.gv_apply_joins({ id: viewId, joins: [] }); + list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 0); + + // get-view-config reflects the cleared state. + const cfg = await h.gv_get_view_config({ id: viewId, include: ['joins'], compact: false }); + TestAssert.isTrue(!cfg.joins || cfg.joins.length === 0, 'cleared joins reflected in get-view-config'); + + await gfClient.deleteForm(f1).catch(() => {}); + await gfClient.deleteForm(f2).catch(() => {}); +}); + suite.run(); diff --git a/src/view-operations/abilities-loader.js b/src/view-operations/abilities-loader.js index d63411f..b2b99aa 100644 --- a/src/view-operations/abilities-loader.js +++ b/src/view-operations/abilities-loader.js @@ -56,6 +56,19 @@ export function methodForAbility(annotations = {}) { return 'POST'; } +/** + * Default ability namespaces surfaced as MCP tools. Both `gk-gravityview/*` + * (core GravityView abilities) and `gk-multiple-forms/*` (the Multiple + * Forms add-on's join surface) share the `gv_*` MCP prefix because + * they're conceptually one product family from the agent's POV. Slugs + * are unique across both namespaces (verified manually); a collision + * would silently shadow one with the other and is worth detecting if + * we add a third namespace. + * + * @type {string[]} + */ +export const DEFAULT_ABILITY_NAMESPACES = ['gk-gravityview', 'gk-multiple-forms']; + /** * Fetch the abilities catalog + build MCP tool definitions and * handlers in a single pass. @@ -65,11 +78,12 @@ export function methodForAbility(annotations = {}) { * * @param {object} gvClient GravityViewClient instance — uses its * authenticated httpClient. - * @param {string} [namespace='gk-gravityview'] Filter abilities by - * namespace prefix. + * @param {string|string[]} [namespaces=DEFAULT_ABILITY_NAMESPACES] + * Filter abilities by namespace prefix. Accepts a single string for + * backward compatibility or an array. * @returns {Promise<{ definitions: object[], handlers: Record, count: number }>} */ -export async function loadAbilitiesAsTools(gvClient, namespace = 'gk-gravityview') { +export async function loadAbilitiesAsTools(gvClient, namespaces = DEFAULT_ABILITY_NAMESPACES) { // gvClient.httpClient is namespaced to /gravityview/v1. The // Abilities API lives at a sibling namespace (/wp-abilities/v1), // so we override baseURL per-request to the WP root rather than @@ -83,7 +97,10 @@ export async function loadAbilitiesAsTools(gvClient, namespace = 'gk-gravityview throw new Error('Unexpected Abilities API catalog shape — expected array.'); } - const ours = data.filter((a) => typeof a?.name === 'string' && a.name.startsWith(namespace + '/')); + const nsList = Array.isArray(namespaces) ? namespaces : [namespaces]; + const ours = data.filter( + (a) => typeof a?.name === 'string' && nsList.some((ns) => a.name.startsWith(ns + '/')), + ); const definitions = []; const handlers = {}; From 2e150c4126b74f0d1becf78451df3704200e77a5 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 15 May 2026 10:29:41 -0400 Subject: [PATCH 05/36] feat(loader): lazy + self-healing abilities catalog fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup-time blocking load was sticky on failure: once the cert / network / WP-not-yet-booted path failed, abilityToolDefinitions stayed null for the lifetime of the Node process and only Claude Code restart (not /mcp reconnect, which doesn't re-fork) recovered. This rewires the loader to: 1. Fire-and-forget eager kickoff in initializeClient() — no startup latency, no blocking the MCP handshake on a slow / down WP. 2. Single-flight ensureAbilitiesLoaded() promise. Concurrent callers share it; on rejection the cache clears so the NEXT call retries. 3. ListTools awaits up to 2s — covers a warm cold-start fetch on dev.test (~800ms) without ever feeling like a hang. 4. Every gv_* tool call awaits with no timeout (caller is willing). Cache hit on the success path = zero overhead. Failures self-heal on next call (sleep/wake, valet still booting, cert mid-fix all recover without an MCP restart). 5. tools/list_changed notification on successful load — clients refetch the catalog so abilities-derived schemas (joins on apply-view-config, gv_apply_joins, gv_list_joins) replace the legacy ones in their cache. 6. New gv_reload_abilities tool — manual escape hatch when you fixed a WP issue and want the refresh now without firing another gv_*. Verified: 140/140 views-stress tests pass against dev.test. --- src/index.js | 144 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 126 insertions(+), 18 deletions(-) diff --git a/src/index.js b/src/index.js index f36729b..66d5c8d 100644 --- a/src/index.js +++ b/src/index.js @@ -47,7 +47,7 @@ const server = new Server( }, { capabilities: { - tools: {} + tools: { listChanged: true } }, instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields) and gv_* for GravityView Views.\n\nGravityView authoring flow: 1) gv_create_view to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_create_grid_row (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_apply_view_config for bulk one-shot writes, or gv_add_view_field / gv_patch_view_field / gv_move_view_field for surgical edits. 4) For Search Bar internal layout, use gv_add_search_field / gv_patch_search_field / gv_remove_search_field — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_list_layouts (Layout Builder, DIY, Table, List, DataTables, Map — with is_grid_aware flag), gv_list_widgets, gv_list_grid_row_types, gv_list_widget_zones (header/footer), gv_list_search_zones (search-general/search-advanced), gv_list_available_fields. Schema: gv_get_field_type_schema works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_move_view_field accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' } @@ -68,6 +68,11 @@ let viewToolHandlers = null; // these stay null and the legacy path serves the gv_* tools. let abilityToolDefinitions = null; let abilityToolHandlers = null; +// In-flight catalog fetch. Single-flight: concurrent callers share the +// same promise. On rejection it's cleared so the NEXT call retries — +// covers transient cert / network / WP-not-yet-booted failures without +// requiring an MCP process restart. +let abilitiesLoadPromise = null; /** * Initialize Gravity Forms client @@ -104,22 +109,13 @@ async function initializeClient() { viewToolHandlers = buildViewToolHandlers(viewOperations); logger.info('✅ GravityView client initialized — gv_* tools available'); - // Try to load the WordPress Abilities API catalog. When it - // exists, every `gk-gravityview/*` ability becomes an MCP tool - // (and replaces its hand-maintained gv_* equivalent on the - // wire). When it doesn't exist (older WP, plugin off, network - // blip), the legacy hand-maintained tool defs serve. No-op - // failure — the existing pipeline is the safety net. - try { - const { definitions, handlers, count } = await loadAbilitiesAsTools(gravityViewClient); - abilityToolDefinitions = definitions; - abilityToolHandlers = handlers; - logger.info(`✅ Loaded ${count} GravityView abilities from /wp-abilities/v1 — replacing legacy gv_* defs`); - } catch (abilitiesError) { - logger.warn(`⚠️ Abilities API catalog unavailable: ${abilitiesError.message} — falling back to legacy hand-maintained tool defs`); - abilityToolDefinitions = null; - abilityToolHandlers = null; - } + // Fire-and-forget: kick off the abilities catalog fetch in the + // background so MCP startup is fast. ListTools awaits up to 2s + // for it (long enough for a warm cold-start on dev.test, short + // enough to never feel like a hang); gv_* tool calls await with + // no timeout. On success we emit `tools/list_changed` so the + // client refetches with the abilities-derived schemas. + ensureAbilitiesLoaded(); } catch (gvError) { // Don't fail the whole MCP if GravityView credentials are // missing — gf_* tools still work standalone. @@ -138,6 +134,62 @@ async function initializeClient() { } } +/** + * Idempotent + self-healing loader for the WordPress Abilities API + * catalog. Single-flight (concurrent callers share a promise), with + * a per-call optional timeout (used by ListTools so a slow / down WP + * doesn't hang the tool list at startup). On rejection the cached + * promise is cleared so the NEXT call retries — sleep/wake, cert + * mid-fix, valet still booting all self-heal on the next gv_* call. + * + * Side effects on success: + * - populates `abilityToolDefinitions` + `abilityToolHandlers` + * - emits `notifications/tools/list_changed` so MCP clients refetch + * + * @param {Object} [opts] + * @param {boolean} [opts.force] Discard cached state and reload. + * @param {number} [opts.timeoutMs] Cap the await; the load itself + * keeps running in the background + * after the timeout fires. + */ +async function ensureAbilitiesLoaded({ force = false, timeoutMs } = {}) { + if (!gravityViewClient) return; + if (force) { + abilityToolDefinitions = null; + abilityToolHandlers = null; + abilitiesLoadPromise = null; + } + if (abilityToolDefinitions) return; + if (!abilitiesLoadPromise) { + abilitiesLoadPromise = loadAbilitiesAsTools(gravityViewClient) + .then(({ definitions, handlers, count }) => { + abilityToolDefinitions = definitions; + abilityToolHandlers = handlers; + logger.info(`✅ Loaded ${count} GravityView abilities from /wp-abilities/v1 — replacing legacy gv_* defs`); + // Tell connected MCP clients to refetch the tool list so the + // abilities-derived schemas (e.g. `joins` on apply-view-config, + // gv_apply_joins, gv_list_joins) replace the legacy ones in + // their cached catalogue. + server.sendToolListChanged().catch((err) => { + logger.warn(`tools/list_changed notification failed: ${err.message}`); + }); + }) + .catch((err) => { + logger.warn(`⚠️ Abilities API catalog unavailable: ${err.message} — falling back to legacy hand-maintained tool defs (will retry on next call)`); + abilitiesLoadPromise = null; // clear so next call retries + throw err; + }); + } + if (timeoutMs) { + await Promise.race([ + abilitiesLoadPromise.catch(() => {}), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + } else { + await abilitiesLoadPromise.catch(() => {}); + } +} + /** * Recursively strip null, empty string, and false values from objects/arrays. * Reduces token usage by removing noise like empty field values and absent meta keys. @@ -227,6 +279,19 @@ function wrapViewHandler(handler, params = {}) { // ================================= server.setRequestHandler(ListToolsRequestSchema, async () => { + // ListTools is the FIRST request Claude fires after the MCP + // handshake, so we lazily initialise here too — otherwise the + // tool list goes out before initializeClient() has had a chance + // to construct the GravityView client + start the abilities load. + if (!gravityFormsClient) { + try { await initializeClient(); } catch (_) { /* gf_* still serves */ } + } + // Best-effort wait for the abilities catalog. 2s covers a warm + // cold-start on dev.test (~800ms) plus headroom; if WP is + // genuinely unreachable we fall through to the legacy tool defs + // and the next gv_* call retries. + await ensureAbilitiesLoaded({ timeoutMs: 2000 }); + return { tools: [ // Forms Management (6 tools) @@ -639,7 +704,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { // hand-maintained list when not. abilityToolDefinitions is // populated by initializeClient() above; both arrays are the // same shape so the spread is uniform. - ...(abilityToolDefinitions ?? viewToolDefinitions) + ...(abilityToolDefinitions ?? viewToolDefinitions), + + // Always present — the manual escape hatch when the eager + // background load fails AND the per-call self-heal hasn't fired + // (e.g. you fixed the WP env and want the tool list refreshed + // without waiting to call another gv_* tool first). + { + name: 'gv_reload_abilities', + description: 'Force a re-fetch of the WordPress Abilities API catalog and refresh the gv_* tool list. Use after fixing a WP / network / cert issue that prevented the eager background load from succeeding at MCP startup.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false + } + } ] }; }); @@ -763,12 +843,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // the switch readable and makes adding/removing tools a one-line // change in view-operations/index.js. default: + if (name === 'gv_reload_abilities') { + if (!gravityViewClient) { + return createErrorResponse( + 'GravityView client not initialized. Set GRAVITYVIEW_BASE_URL + GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD in .env (or reuse the GRAVITY_FORMS_* credentials).' + ); + } + const before = abilityToolDefinitions?.length ?? 0; + await ensureAbilitiesLoaded({ force: true }); + const after = abilityToolDefinitions?.length ?? 0; + return { + content: [{ + type: 'text', + text: JSON.stringify({ + loaded: !!abilityToolDefinitions, + ability_tool_count: after, + previous_count: before, + note: abilityToolDefinitions + ? 'Catalog refreshed. Clients receive `notifications/tools/list_changed` automatically.' + : 'Catalog still unreachable — check WP logs / cert / credentials. Will retry on next gv_* tool call.', + }, null, 2), + }], + }; + } if (typeof name === 'string' && name.startsWith('gv_')) { if (!gravityViewClient) { return createErrorResponse( 'GravityView client not initialized. Set GRAVITYVIEW_BASE_URL + GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD in .env (or reuse GRAVITY_FORMS_BASE_URL / GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET when the same WP install hosts both surfaces).' ); } + // Self-heal: every gv_* call retries the abilities load if a + // prior attempt failed. Cheap (cache hit on success path), + // and lets transient cert / network / boot races recover + // without an MCP process restart. + await ensureAbilitiesLoaded(); // Prefer the abilities-derived handler when the WordPress // Abilities API catalog was reachable at startup. Falls back // to the legacy hand-maintained handler map when not — both From 0c0257d28abd0c93437c948297da519b496c1de5 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Wed, 20 May 2026 08:34:53 -0400 Subject: [PATCH 06/36] fix(loader): wrap auto-generated tool inputSchema as JSON Schema object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools 29–36 (auto-generated from the abilities catalog) emitted `inputSchema` as a raw parameter array; another tool emitted `properties` as an array (PHP-empty-assoc → JSON `[]`). Both forms fail Zod validation during MCP `tools/list`, which broke every gv_* tool on reconnect. `normalizeInputSchema()` in abilities-loader coerces both shapes into `{ type: "object", properties: >, … }`, deriving keys from each descriptor's name/slug/key when wrapping an array, lifting `required: true` to the outer `required` array, and preserving any sibling JSON Schema keys (additionalProperties, description, etc.). Adds src/tests/abilities-loader.test.js (16 tests) including the MCP-contract assertion every generated tool's inputSchema must satisfy: object root, type === 'object', properties is a non-array object. Reproduces both wire-format failures via a synthetic catalog so we never regress on the empty-properties or array-input-schema shapes again. --- src/tests/abilities-loader.test.js | 265 ++++++++++++++++++++++++ src/tests/run.js | 4 +- src/view-operations/abilities-loader.js | 103 ++++++++- 3 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 src/tests/abilities-loader.test.js diff --git a/src/tests/abilities-loader.test.js b/src/tests/abilities-loader.test.js new file mode 100644 index 0000000..20501bf --- /dev/null +++ b/src/tests/abilities-loader.test.js @@ -0,0 +1,265 @@ +/** + * Tests for the GravityView abilities-loader. + * + * Guards the MCP `tools/list` contract — every auto-generated tool MUST + * present an `inputSchema` of shape `{ type: 'object', properties: , … }` + * or Claude Code's MCP client rejects the entire catalog with a Zod + * validation error (this happened in the wild: tools 29–36 had an array + * `inputSchema`, tool 57 had `properties: []`). + */ + +import { TestRunner, TestAssert } from './helpers.js'; +import { + normalizeInputSchema, + loadAbilitiesAsTools, + abilityNameToToolName, + methodForAbility, +} from '../view-operations/abilities-loader.js'; + +const suite = new TestRunner('Abilities Loader Tests'); + +/** + * The MCP-contract assertion: every generated tool's inputSchema must + * satisfy these invariants. Mirrors the shape `@modelcontextprotocol/sdk` + * validates with Zod under `ListToolsRequestSchema`. + */ +function assertValidMcpInputSchema(schema, label = 'inputSchema') { + TestAssert.isTrue( + schema !== null && typeof schema === 'object' && !Array.isArray(schema), + `${label}: must be a plain object, got ${Array.isArray(schema) ? 'array' : typeof schema}`, + ); + TestAssert.equal(schema.type, 'object', `${label}.type must be "object"`); + TestAssert.isTrue( + schema.properties !== null + && typeof schema.properties === 'object' + && !Array.isArray(schema.properties), + `${label}.properties must be a Record, got ${Array.isArray(schema.properties) ? 'array' : typeof schema.properties}`, + ); +} + +// --------------------------------------------------------------------------- +// normalizeInputSchema unit tests — cover every shape we've seen the WP +// Abilities API emit (or any shape PHP could plausibly emit). +// --------------------------------------------------------------------------- + +suite.test('normalizeInputSchema: passes a valid schema through unchanged', () => { + const valid = { + type: 'object', + properties: { id: { type: 'integer' }, name: { type: 'string' } }, + required: ['id'], + }; + const out = normalizeInputSchema(valid); + assertValidMcpInputSchema(out); + TestAssert.deepEqual(out.properties, valid.properties); + TestAssert.deepEqual(out.required, ['id']); +}); + +suite.test('normalizeInputSchema: wraps a top-level array (tools 29-36 bug)', () => { + // The bug Claude Code surfaced: abilities 29-36 emitted `input_schema` + // as a raw array, blowing MCP's `expected object, received array` Zod check. + const arrayShaped = [ + { name: 'view_id', type: 'integer', required: true, description: 'The View ID.' }, + { name: 'compact', type: 'boolean', description: 'Strip empty fields.' }, + ]; + const out = normalizeInputSchema(arrayShaped); + assertValidMcpInputSchema(out); + TestAssert.isTrue('view_id' in out.properties, 'view_id property derived from entry.name'); + TestAssert.isTrue('compact' in out.properties, 'compact property derived from entry.name'); + TestAssert.deepEqual(out.required, ['view_id'], 'required: true lifts to outer required array'); + // Ensure the descriptor's `name` was stripped from the value (now it's the key). + TestAssert.equal(out.properties.view_id.name, undefined); + TestAssert.equal(out.properties.view_id.type, 'integer'); +}); + +suite.test('normalizeInputSchema: coerces properties: [] (tool 57 bug)', () => { + // PHP serialises an empty associative array as JSON `[]`. When `properties` + // hits the MCP client like that, Zod fails with `expected record, received array`. + const objectWithArrayProps = { type: 'object', properties: [] }; + const out = normalizeInputSchema(objectWithArrayProps); + assertValidMcpInputSchema(out); + TestAssert.deepEqual(out.properties, {}); +}); + +suite.test('normalizeInputSchema: coerces properties: [descriptor, …]', () => { + // Non-empty array under `properties`, same descriptor format as the + // top-level-array case but nested. Treat it as a property descriptor list. + const schema = { + type: 'object', + properties: [ + { name: 'slot', type: 'integer' }, + { name: 'ref', type: 'string', required: true }, + ], + }; + const out = normalizeInputSchema(schema); + assertValidMcpInputSchema(out); + TestAssert.deepEqual(Object.keys(out.properties).sort(), ['ref', 'slot']); + TestAssert.deepEqual(out.required, ['ref']); +}); + +suite.test('normalizeInputSchema: missing input_schema → open object', () => { + for (const empty of [undefined, null, false]) { + const out = normalizeInputSchema(empty); + assertValidMcpInputSchema(out, `normalize(${empty})`); + TestAssert.deepEqual(out.properties, {}); + TestAssert.equal(out.additionalProperties, true); + } +}); + +suite.test('normalizeInputSchema: forces type:"object" when upstream omits it', () => { + // Some abilities ship `properties` but forget `type` — common in + // hand-written PHP schemas. + const out = normalizeInputSchema({ properties: { id: { type: 'integer' } } }); + assertValidMcpInputSchema(out); + TestAssert.equal(out.type, 'object'); +}); + +suite.test('normalizeInputSchema: preserves sibling keys (required, additionalProperties, …)', () => { + const out = normalizeInputSchema({ + type: 'object', + properties: { foo: { type: 'string' } }, + required: ['foo'], + additionalProperties: false, + description: 'A widget', + }); + TestAssert.deepEqual(out.required, ['foo']); + TestAssert.equal(out.additionalProperties, false); + TestAssert.equal(out.description, 'A widget'); +}); + +suite.test('normalizeInputSchema: anonymous array entries get arg keys', () => { + // Defensive: if a descriptor lacks name/slug/key/title, we shouldn't + // silently drop it — we synthesize a key so the agent can still bind it. + const out = normalizeInputSchema([{ type: 'string' }, { type: 'integer' }]); + assertValidMcpInputSchema(out); + TestAssert.deepEqual(Object.keys(out.properties).sort(), ['arg0', 'arg1']); +}); + +suite.test('normalizeInputSchema: never mutates its input', () => { + const input = { type: 'object', properties: [] }; + const before = JSON.stringify(input); + normalizeInputSchema(input); + TestAssert.equal(JSON.stringify(input), before, 'input was mutated'); +}); + +// --------------------------------------------------------------------------- +// Integration: drive loadAbilitiesAsTools with a synthetic catalog that +// reproduces the wire-format Zod failures, and confirm every generated +// tool now satisfies the MCP contract. +// --------------------------------------------------------------------------- + +/** Stub gvClient — only `httpClient.request` and `baseUrl` are exercised. */ +function buildStubGvClient(catalog) { + return { + baseUrl: 'https://test.invalid', + httpClient: { + request: async () => ({ data: catalog }), + }, + }; +} + +/** Synthetic catalog covering the three failure modes + a healthy ability. */ +function syntheticCatalog() { + return [ + // Healthy reference — must round-trip untouched. + { + name: 'gk-gravityview/list-layouts', + description: 'List installed layouts', + input_schema: { type: 'object', properties: { compact: { type: 'boolean' } } }, + meta: { annotations: { readonly: true } }, + }, + // Bug shape #1 — input_schema is itself an array (tools 29-36). + { + name: 'gk-gravityview/add-view-field', + description: 'Add a field to a View', + input_schema: [ + { name: 'view_id', type: 'integer', required: true }, + { name: 'field_id', type: 'string', required: true }, + ], + meta: { annotations: {} }, + }, + // Bug shape #2 — properties is an array (tool 57). + { + name: 'gk-multiple-forms/list-joins', + description: 'List joins', + input_schema: { type: 'object', properties: [] }, + meta: { annotations: { readonly: true } }, + }, + // Outside our namespaces — must be filtered out. + { + name: 'core/unrelated-ability', + description: 'Should not be exposed', + input_schema: { type: 'object', properties: {} }, + meta: { annotations: {} }, + }, + ]; +} + +suite.test('loadAbilitiesAsTools: filters to gk-gravityview/* + gk-multiple-forms/*', async () => { + const { definitions, count } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); + TestAssert.equal(count, 3, 'expected 3 in-namespace abilities, got ' + count); + TestAssert.equal(definitions.length, 3, 'definitions count must match'); + const names = definitions.map((d) => d.name).sort(); + TestAssert.deepEqual(names, ['gv_add_view_field', 'gv_list_joins', 'gv_list_layouts']); +}); + +suite.test('loadAbilitiesAsTools: EVERY generated tool has a valid MCP inputSchema', async () => { + // The contract check that would have caught the production regression. + const catalog = syntheticCatalog(); + const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(catalog)); + for (const def of definitions) { + assertValidMcpInputSchema(def.inputSchema, `${def.name}.inputSchema`); + } +}); + +suite.test('loadAbilitiesAsTools: tools 29-36 repro — array input_schema is wrapped', async () => { + const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); + const tool = definitions.find((d) => d.name === 'gv_add_view_field'); + TestAssert.isTrue(!!tool, 'gv_add_view_field must exist'); + assertValidMcpInputSchema(tool.inputSchema); + TestAssert.isTrue('view_id' in tool.inputSchema.properties); + TestAssert.isTrue('field_id' in tool.inputSchema.properties); + TestAssert.deepEqual(tool.inputSchema.required.sort(), ['field_id', 'view_id']); +}); + +suite.test('loadAbilitiesAsTools: tool 57 repro — properties:[] becomes properties:{}', async () => { + const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); + const tool = definitions.find((d) => d.name === 'gv_list_joins'); + TestAssert.isTrue(!!tool, 'gv_list_joins must exist'); + assertValidMcpInputSchema(tool.inputSchema); + TestAssert.deepEqual(tool.inputSchema.properties, {}); +}); + +suite.test('loadAbilitiesAsTools: healthy schema passes through untouched', async () => { + const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); + const tool = definitions.find((d) => d.name === 'gv_list_layouts'); + TestAssert.isTrue(!!tool); + TestAssert.deepEqual(tool.inputSchema.properties, { compact: { type: 'boolean' } }); +}); + +// --------------------------------------------------------------------------- +// Lightweight smoke for the existing helpers — these have no tests yet and +// regressions here would silently mis-route every gv_* call. +// --------------------------------------------------------------------------- + +suite.test('abilityNameToToolName: strips namespace, kebab → snake', () => { + TestAssert.equal(abilityNameToToolName('gk-gravityview/list-layouts'), 'gv_list_layouts'); + TestAssert.equal(abilityNameToToolName('gk-multiple-forms/list-joins'), 'gv_list_joins'); + TestAssert.equal(abilityNameToToolName('gk-gravityview/apply-view-config'), 'gv_apply_view_config'); +}); + +suite.test('methodForAbility: readonly → GET, destructive → DELETE, else POST', () => { + TestAssert.equal(methodForAbility({ readonly: true }), 'GET'); + TestAssert.equal(methodForAbility({ destructive: true }), 'DELETE'); + TestAssert.equal(methodForAbility({}), 'POST'); + TestAssert.equal(methodForAbility(), 'POST'); +}); + +// Standalone runner +const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*\//, '')); +if (isMain) { + suite.run().then((results) => { + process.exit(results.failed > 0 ? 1 : 0); + }); +} + +export default suite; diff --git a/src/tests/run.js b/src/tests/run.js index d88c486..3350d66 100644 --- a/src/tests/run.js +++ b/src/tests/run.js @@ -16,6 +16,7 @@ import validationTests from './validation.test.js'; import sanitizeTests from './sanitize.test.js'; import bugFixesTests from './bug-fixes.test.js'; import mutexTests from './mutex.test.js'; +import abilitiesLoaderTests from './abilities-loader.test.js'; // Test suites to run const testSuites = [ @@ -29,7 +30,8 @@ const testSuites = [ validationTests, sanitizeTests, mutexTests, - bugFixesTests + bugFixesTests, + abilitiesLoaderTests ]; // Test statistics diff --git a/src/view-operations/abilities-loader.js b/src/view-operations/abilities-loader.js index b2b99aa..0613725 100644 --- a/src/view-operations/abilities-loader.js +++ b/src/view-operations/abilities-loader.js @@ -56,6 +56,98 @@ export function methodForAbility(annotations = {}) { return 'POST'; } +/** + * Coerce an ability's `input_schema` payload into a JSON Schema object the + * MCP runtime can validate (`{ type: "object", properties: {...} }`). + * + * Two shapes from the WordPress Abilities API need normalising before they + * hit MCP's Zod validator: + * + * 1. `input_schema` is itself an array — happens when the PHP side returns + * a list of parameter descriptors instead of a schema object. We wrap + * it as `{ type: 'object', properties: {}, required: [] }`, + * pulling each entry's `name` / `slug` / `key` as the property key when + * present. Anonymous entries fall back to `arg`. + * 2. `input_schema.properties` is an array (almost always `[]` from + * PHP serialising an empty associative array as a JSON list). MCP + * expects `properties` to be a `Record` — we + * coerce empty arrays to `{}` and non-empty arrays via the same + * per-entry key derivation as case 1. + * + * Returns a fresh object — never mutates the input. + * + * @param {unknown} raw The `input_schema` value as received from the API. + * @returns {{ type: 'object', properties: object, required?: string[], additionalProperties?: boolean }} + */ +export function normalizeInputSchema(raw) { + // Missing / falsy → open object so the tool is still callable. + if (raw === null || raw === undefined || raw === false) { + return { type: 'object', properties: {}, additionalProperties: true }; + } + + // Shape 1: top-level array of parameter descriptors. + if (Array.isArray(raw)) { + const { properties, required } = arrayToProperties(raw); + const out = { type: 'object', properties }; + if (required.length) out.required = required; + return out; + } + + // Anything that isn't an object at this point is unusable — fall back + // to an open object rather than letting Zod blow up downstream. + if (typeof raw !== 'object') { + return { type: 'object', properties: {}, additionalProperties: true }; + } + + // Shape 2: object whose `properties` is an array (PHP-serialised empty + // assoc array, or a list of descriptors). Normalise it but keep every + // other key the upstream provided (e.g. `required`, `additionalProperties`, + // `description`, custom `$schema` extensions). + const out = { ...raw }; + if (out.type !== 'object') out.type = 'object'; + + if (Array.isArray(out.properties)) { + const { properties, required } = arrayToProperties(out.properties); + out.properties = properties; + if (required.length && !Array.isArray(out.required)) { + out.required = required; + } + } else if (out.properties === null || out.properties === undefined) { + out.properties = {}; + } else if (typeof out.properties !== 'object') { + out.properties = {}; + } + + return out; +} + +/** + * Convert a list of parameter descriptors into a `properties` map + + * `required` list. Each entry contributes one property; the key is + * derived from `name` / `slug` / `key` / `title` (in that order), or + * `arg` for anonymous entries. The descriptor is copied as the + * value, with the chosen identifier key stripped so it doesn't double + * as both the map key and a redundant schema field. An entry's + * `required: true` (or string "true") lifts the property into the + * outer `required` array — JSON Schema requires it there, not per-prop. + */ +function arrayToProperties(arr) { + const properties = {}; + const required = []; + arr.forEach((entry, i) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + // Non-object entries can't be expressed as a JSON Schema property; + // skip rather than fabricate a placeholder of unknown intent. + return; + } + const key = entry.name || entry.slug || entry.key || entry.title || `arg${i}`; + const { name: _n, slug: _s, key: _k, required: req, ...rest } = entry; + properties[key] = rest; + if (req === true || req === 'true') required.push(key); + }); + return { properties, required }; +} + /** * Default ability namespaces surfaced as MCP tools. Both `gk-gravityview/*` * (core GravityView abilities) and `gk-multiple-forms/*` (the Multiple @@ -109,13 +201,16 @@ export async function loadAbilitiesAsTools(gvClient, namespaces = DEFAULT_ABILIT const toolName = abilityNameToToolName(ability.name); const annotations = ability?.meta?.annotations || {}; - // MCP tool definition. inputSchema defaults to an open object - // when the ability has no declared input — callers can still - // pass keys; the server will validate per its own schema. + // MCP tool definition. `normalizeInputSchema()` guarantees the + // shape MCP's Zod validator expects: + // `{ type: 'object', properties: >, … }`. + // Without it, abilities whose PHP serialisation produced an array + // (top-level or under `properties`) fail `tools/list` validation — + // see the helper's docblock for the two shapes we coerce. definitions.push({ name: toolName, description: ability.description || ability.label || ability.name, - inputSchema: ability.input_schema || { type: 'object', properties: {}, additionalProperties: true }, + inputSchema: normalizeInputSchema(ability.input_schema), }); // Closure captures the ability name + method so the dispatcher From 7474ab0e17e07f6b90f4b80dd4ea13c6dc9cb3d2 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 11 Jun 2026 15:24:30 -0400 Subject: [PATCH 07/36] feat(loader): align gv_* tools to the Foundation Abilities contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source chain: Foundation catalog (/wp-json/gravitykit/v1/abilities) first — server-side GK filtering, disabled omitted, paginated — with WP core (/wp-abilities/v1/abilities) as fallback for connections that can't pass the catalog's manage_options gate, filtered on meta.gk_registered_by. The server now owns tool naming exclusively (mcp_tool_name from Foundation's MCP_TOOL_PREFIXES). Removed the client-side gv_ name derivation and the hardcoded namespace allow-list — both were compat with code that never shipped (main/npm 2.1.1 are GF-only, no abilities). Abilities arriving without mcp_tool_name are skipped with a warning. Removed the never-released hand-maintained gv_* tool definitions and handlers (src/view-operations/index.js) and all fallback wiring; the abilities catalogs are the only source of gv_* tools. When unreachable, gv_* tools are absent and the per-call self-heal / gv_reload_abilities retries. Added a tool-name collision guard (first claimant wins, later ones logged and skipped) and updated the server instructions string to the ability-derived tool names (gv_view_create, gv_layouts_list, …). Requires server-side: Foundation to stamp mcp_tool_name into ability meta (for the WP-core fallback path) and/or relax the gravitykit/v1 catalog permission so non-admin authors can list abilities. --- src/index.js | 65 ++- src/tests/abilities-loader.test.js | 230 +++++++++-- src/view-operations/abilities-loader.js | 294 +++++++++---- src/view-operations/index.js | 522 ------------------------ 4 files changed, 443 insertions(+), 668 deletions(-) delete mode 100644 src/view-operations/index.js diff --git a/src/index.js b/src/index.js index 66d5c8d..fac0cdd 100644 --- a/src/index.js +++ b/src/index.js @@ -23,11 +23,6 @@ import logger from './utils/logger.js'; import { sanitize } from './utils/sanitize.js'; import { stripEmpty, stripEntryMetaFromResponse } from './utils/compact.js'; import { GravityViewClient } from './gravityview-client.js'; -import { - createViewOperations, - viewToolDefinitions, - buildViewToolHandlers, -} from './view-operations/index.js'; import { loadAbilitiesAsTools } from './view-operations/abilities-loader.js'; const __filename = fileURLToPath(import.meta.url); @@ -49,7 +44,7 @@ const server = new Server( capabilities: { tools: { listChanged: true } }, - instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields) and gv_* for GravityView Views.\n\nGravityView authoring flow: 1) gv_create_view to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_create_grid_row (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_apply_view_config for bulk one-shot writes, or gv_add_view_field / gv_patch_view_field / gv_move_view_field for surgical edits. 4) For Search Bar internal layout, use gv_add_search_field / gv_patch_search_field / gv_remove_search_field — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_list_layouts (Layout Builder, DIY, Table, List, DataTables, Map — with is_grid_aware flag), gv_list_widgets, gv_list_grid_row_types, gv_list_widget_zones (header/footer), gv_list_search_zones (search-general/search-advanced), gv_list_available_fields. Schema: gv_get_field_type_schema works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_move_view_field accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' + instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields) and gv_* for GravityView Views.\n\nGravityView authoring flow: 1) gv_view_create to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_grid_row_add (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_view_config_apply for bulk one-shot writes, or gv_view_field_add / gv_view_field_patch / gv_view_field_move for surgical edits. 4) For Search Bar internal layout, use gv_search_field_add / gv_search_field_patch / gv_search_field_remove — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_layouts_list (Layout Builder, DIY, Table, List, DataTables, Map — with is_grid_aware flag), gv_widgets_list, gv_grid_row_types_list, gv_widget_zones_list (header/footer), gv_search_zones_list (search-general/search-advanced), gv_available_fields_get. Schema: gv_field_type_schema_get works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_view_field_move accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' } ); @@ -58,14 +53,11 @@ let gravityFormsClient = null; let fieldOperations = null; let fieldValidator = null; let gravityViewClient = null; -let viewOperations = null; -let viewToolHandlers = null; -// Auto-generated from the WordPress Abilities API catalog. Populated -// by initializeClient(). When present, abilityToolDefinitions REPLACES -// the hand-maintained viewToolDefinitions in the tool list, and -// abilityToolHandlers REPLACES the legacy switch for gv_* dispatch. -// When the abilities catalog is unreachable (older WP, plugin off), -// these stay null and the legacy path serves the gv_* tools. +// Auto-generated from the WordPress Abilities API (Foundation catalog +// first, WP core fallback). Populated by initializeClient(). This is +// the ONLY source of gv_* tools — when no catalog is reachable (older +// WP, plugin off), these stay null, gv_* tools are absent from +// tools/list, and every gv_* call retries the load (self-healing). let abilityToolDefinitions = null; let abilityToolHandlers = null; // In-flight catalog fetch. Single-flight: concurrent callers share the @@ -105,9 +97,7 @@ async function initializeClient() { // two separate app passwords. try { gravityViewClient = new GravityViewClient(process.env); - viewOperations = createViewOperations(gravityViewClient); - viewToolHandlers = buildViewToolHandlers(viewOperations); - logger.info('✅ GravityView client initialized — gv_* tools available'); + logger.info('✅ GravityView client initialized — loading gv_* abilities'); // Fire-and-forget: kick off the abilities catalog fetch in the // background so MCP startup is fast. ListTools awaits up to 2s @@ -121,8 +111,6 @@ async function initializeClient() { // missing — gf_* tools still work standalone. logger.warn(`⚠️ GravityView client unavailable: ${gvError.message}`); gravityViewClient = null; - viewOperations = null; - viewToolHandlers = null; abilityToolDefinitions = null; abilityToolHandlers = null; } @@ -162,20 +150,20 @@ async function ensureAbilitiesLoaded({ force = false, timeoutMs } = {}) { if (abilityToolDefinitions) return; if (!abilitiesLoadPromise) { abilitiesLoadPromise = loadAbilitiesAsTools(gravityViewClient) - .then(({ definitions, handlers, count }) => { + .then(({ definitions, handlers, count, source }) => { abilityToolDefinitions = definitions; abilityToolHandlers = handlers; - logger.info(`✅ Loaded ${count} GravityView abilities from /wp-abilities/v1 — replacing legacy gv_* defs`); + const sourceLabel = source === 'foundation-catalog' ? 'gravitykit/v1 catalog' : '/wp-abilities/v1'; + logger.info(`✅ Loaded ${count} GravityKit abilities from ${sourceLabel}`); // Tell connected MCP clients to refetch the tool list so the - // abilities-derived schemas (e.g. `joins` on apply-view-config, - // gv_apply_joins, gv_list_joins) replace the legacy ones in - // their cached catalogue. + // abilities-derived schemas (e.g. `joins` on view-config-apply, + // gk_apply_joins, gk_list_joins) land in their cached catalogue. server.sendToolListChanged().catch((err) => { logger.warn(`tools/list_changed notification failed: ${err.message}`); }); }) .catch((err) => { - logger.warn(`⚠️ Abilities API catalog unavailable: ${err.message} — falling back to legacy hand-maintained tool defs (will retry on next call)`); + logger.warn(`⚠️ Abilities API catalog unavailable: ${err.message} — gv_* tools unavailable until a catalog is reachable (will retry on next call)`); abilitiesLoadPromise = null; // clear so next call retries throw err; }); @@ -288,8 +276,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { } // Best-effort wait for the abilities catalog. 2s covers a warm // cold-start on dev.test (~800ms) plus headroom; if WP is - // genuinely unreachable we fall through to the legacy tool defs - // and the next gv_* call retries. + // genuinely unreachable the list ships without gv_* tools and the + // next gv_* call (or gv_reload_abilities) retries. await ensureAbilitiesLoaded({ timeoutMs: 2000 }); return { @@ -700,11 +688,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { ...fieldOperationTools, // GravityView Inspector — auto-generated from the WordPress - // Abilities API catalog when available, falls back to the - // hand-maintained list when not. abilityToolDefinitions is - // populated by initializeClient() above; both arrays are the - // same shape so the spread is uniform. - ...(abilityToolDefinitions ?? viewToolDefinitions), + // Abilities API (Foundation catalog first, WP core fallback). + // Empty until the background load succeeds; gv_reload_abilities + // and the per-call self-heal repopulate it. + ...(abilityToolDefinitions ?? []), // Always present — the manual escape hatch when the eager // background load fails AND the per-call self-heal hasn't fired @@ -839,9 +826,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }, params)(); // GravityView Inspector — every gv_* tool routes through the - // shared handler map. Single dispatch instead of 20 cases keeps - // the switch readable and makes adding/removing tools a one-line - // change in view-operations/index.js. + // abilities-derived handler map. Single dispatch keeps the switch + // readable; the map is rebuilt whenever the abilities catalog is + // (re)fetched. default: if (name === 'gv_reload_abilities') { if (!gravityViewClient) { @@ -877,14 +864,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // and lets transient cert / network / boot races recover // without an MCP process restart. await ensureAbilitiesLoaded(); - // Prefer the abilities-derived handler when the WordPress - // Abilities API catalog was reachable at startup. Falls back - // to the legacy hand-maintained handler map when not — both - // serve the same gv_* tool name to keep the wire compatible. - const handlerMap = abilityToolHandlers ?? viewToolHandlers; + const handlerMap = abilityToolHandlers; if (!handlerMap) { return createErrorResponse( - 'GravityView tool handlers not initialized.' + 'GravityView abilities catalog unreachable — no gv_* tools are available. Fix WP connectivity / credentials, then call gv_reload_abilities to refresh.' ); } const handler = handlerMap[name]; diff --git a/src/tests/abilities-loader.test.js b/src/tests/abilities-loader.test.js index 20501bf..e8a45a2 100644 --- a/src/tests/abilities-loader.test.js +++ b/src/tests/abilities-loader.test.js @@ -12,8 +12,9 @@ import { TestRunner, TestAssert } from './helpers.js'; import { normalizeInputSchema, loadAbilitiesAsTools, - abilityNameToToolName, methodForAbility, + FOUNDATION_CATALOG_ROUTE, + CORE_ABILITIES_ROUTE, } from '../view-operations/abilities-loader.js'; const suite = new TestRunner('Abilities Loader Tests'); @@ -147,44 +148,94 @@ suite.test('normalizeInputSchema: never mutates its input', () => { // tool now satisfies the MCP contract. // --------------------------------------------------------------------------- -/** Stub gvClient — only `httpClient.request` and `baseUrl` are exercised. */ +/** + * Stub gvClient for the WP-core fallback path: the Foundation catalog + * 404s (older Foundation without gravitykit/v1), the core catalog + * serves `catalog`. Records every request config in `requests`. + */ function buildStubGvClient(catalog) { + const requests = []; return { baseUrl: 'https://test.invalid', + requests, httpClient: { - request: async () => ({ data: catalog }), + request: async (config) => { + requests.push(config); + if (config.url === FOUNDATION_CATALOG_ROUTE) { + const err = new Error('Request failed with status code 404'); + err.response = { status: 404 }; + throw err; + } + return { data: catalog, headers: {} }; + }, }, }; } -/** Synthetic catalog covering the three failure modes + a healthy ability. */ +/** + * Stub gvClient whose Foundation catalog responds with the given pages + * (array of item-arrays; X-WP-TotalPages = pages.length). Core-catalog + * requests serve `coreCatalog`. Records every request config in + * `requests` so tests can assert handler execution wiring. + */ +function buildCatalogStubGvClient(pages, { coreCatalog = [] } = {}) { + const requests = []; + return { + baseUrl: 'https://test.invalid', + requests, + httpClient: { + request: async (config) => { + requests.push(config); + if (config.url === FOUNDATION_CATALOG_ROUTE) { + const page = config.params?.page || 1; + return { + data: pages[page - 1] || [], + headers: { 'x-wp-totalpages': String(pages.length) }, + }; + } + if (config.url === CORE_ABILITIES_ROUTE) { + return { data: coreCatalog, headers: {} }; + } + // Ability /run executions. + return { data: { ok: true }, headers: {} }; + }, + }, + }; +} + +/** + * Synthetic WP-core catalog covering the three failure modes + a healthy + * ability. GravityKit items carry the `gk_registered_by` stamp and the + * `mcp_tool_name` Foundation applies to every ability it registers — + * the core-path filter + naming contract. + */ function syntheticCatalog() { return [ // Healthy reference — must round-trip untouched. { - name: 'gk-gravityview/list-layouts', + name: 'gk-gravityview/layouts-list', description: 'List installed layouts', input_schema: { type: 'object', properties: { compact: { type: 'boolean' } } }, - meta: { annotations: { readonly: true } }, + meta: { gk_registered_by: 'gravitykit', mcp_tool_name: 'gv_layouts_list', annotations: { readonly: true } }, }, // Bug shape #1 — input_schema is itself an array (tools 29-36). { - name: 'gk-gravityview/add-view-field', + name: 'gk-gravityview/view-field-add', description: 'Add a field to a View', input_schema: [ { name: 'view_id', type: 'integer', required: true }, { name: 'field_id', type: 'string', required: true }, ], - meta: { annotations: {} }, + meta: { gk_registered_by: 'gravitykit', mcp_tool_name: 'gv_view_field_add', annotations: {} }, }, // Bug shape #2 — properties is an array (tool 57). { name: 'gk-multiple-forms/list-joins', description: 'List joins', input_schema: { type: 'object', properties: [] }, - meta: { annotations: { readonly: true } }, + meta: { gk_registered_by: 'gravitykit', mcp_tool_name: 'gk_list_joins', annotations: { readonly: true } }, }, - // Outside our namespaces — must be filtered out. + // Another plugin's ability — no Foundation stamp, must be filtered out. { name: 'core/unrelated-ability', description: 'Should not be exposed', @@ -194,12 +245,147 @@ function syntheticCatalog() { ]; } -suite.test('loadAbilitiesAsTools: filters to gk-gravityview/* + gk-multiple-forms/*', async () => { - const { definitions, count } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); - TestAssert.equal(count, 3, 'expected 3 in-namespace abilities, got ' + count); +suite.test('core fallback: filters on Foundation\'s gk_registered_by stamp, unstamped abilities excluded', async () => { + const { definitions, count, source } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); + TestAssert.equal(source, 'wp-core', 'catalog 404 must route to the WP-core path'); + TestAssert.equal(count, 3, 'expected 3 stamped abilities, got ' + count); TestAssert.equal(definitions.length, 3, 'definitions count must match'); const names = definitions.map((d) => d.name).sort(); - TestAssert.deepEqual(names, ['gv_add_view_field', 'gv_list_joins', 'gv_list_layouts']); + TestAssert.deepEqual(names, ['gk_list_joins', 'gv_layouts_list', 'gv_view_field_add']); +}); + +suite.test('core fallback: cross-product abilities included; meta.mcp_tool_name beats gv_ derivation', async () => { + const catalog = [ + ...syntheticCatalog(), + { + // A different GravityKit product — included via the same stamp, + // named by the server, not the gv_ derivation. + name: 'gk-gravitycharts/charts-list', + description: 'List charts', + input_schema: { type: 'object', properties: {} }, + meta: { + gk_registered_by: 'gravitykit', + mcp_tool_name: 'gc_charts_list', + annotations: { readonly: true }, + }, + }, + ]; + const { definitions, source } = await loadAbilitiesAsTools(buildStubGvClient(catalog)); + TestAssert.equal(source, 'wp-core'); + const names = definitions.map((d) => d.name).sort(); + TestAssert.deepEqual(names, ['gc_charts_list', 'gk_list_joins', 'gv_layouts_list', 'gv_view_field_add']); +}); + +// --------------------------------------------------------------------------- +// Foundation catalog path — the canonical source. Items use the +// gravitykit/v1 Manager::to_rest_item() shape: top-level `annotations`, +// `enabled`, `mcp_tool_name`; already GravityKit-only server-side. +// --------------------------------------------------------------------------- + +function syntheticFoundationCatalog() { + return [ + { + name: 'gk-gravityview/views-list', + label: 'List Views', + description: 'List editable Views.', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gv_views_list', + }, + { + // Cross-product — the catalog path trusts the server's + // GravityKit-only filtering; no client-side product list. + name: 'gk-gravitycharts/charts-list', + label: 'List Charts', + description: 'List charts.', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gc_charts_list', + }, + { + // Defensive: the server omits disabled by default, but if one + // arrives flagged enabled:false it must be skipped. + name: 'gk-gravityview/view-status-set', + description: 'Disabled ability', + input_schema: { type: 'object', properties: {} }, + annotations: {}, + enabled: false, + mcp_tool_name: 'gv_view_status_set', + }, + { + // No mcp_tool_name → must be SKIPPED with a warning; the client + // never invents tool names. + name: 'gk-gravityview/layouts-list', + description: 'List layouts', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + }, + ]; +} + +suite.test('catalog path: server-owned naming; disabled and unnamed items skipped', async () => { + const stub = buildCatalogStubGvClient([syntheticFoundationCatalog()]); + const { definitions, count, source } = await loadAbilitiesAsTools(stub); + TestAssert.equal(source, 'foundation-catalog'); + const names = definitions.map((d) => d.name).sort(); + TestAssert.deepEqual(names, ['gc_charts_list', 'gv_views_list']); + TestAssert.equal(count, 2); +}); + +suite.test('catalog path: handlers execute via /wp-abilities/v1 run route with annotation-derived method', async () => { + const stub = buildCatalogStubGvClient([syntheticFoundationCatalog()]); + const { handlers } = await loadAbilitiesAsTools(stub); + await handlers.gc_charts_list({}); + const run = stub.requests.find((r) => typeof r.url === 'string' && r.url.includes('/run')); + TestAssert.isTrue(!!run, 'handler must hit the run endpoint'); + TestAssert.equal(run.url, '/wp-json/wp-abilities/v1/abilities/gk-gravitycharts/charts-list/run'); + TestAssert.equal(run.method, 'GET'); +}); + +suite.test('catalog path: paginates via X-WP-TotalPages', async () => { + const items = syntheticFoundationCatalog(); + const stub = buildCatalogStubGvClient([[items[0]], [items[1]]]); + const { count, source } = await loadAbilitiesAsTools(stub); + TestAssert.equal(source, 'foundation-catalog'); + TestAssert.equal(count, 2); +}); + +suite.test('catalog path: tool-name collision — first wins, later skipped, never shadowed', async () => { + const colliding = [ + { + name: 'gk-gravityview/views-list', + description: 'first claimant', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gv_views_list', + }, + { + name: 'gk-gravityboard/views-list', + description: 'colliding claimant', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gv_views_list', + }, + ]; + const stub = buildCatalogStubGvClient([colliding]); + const { definitions, handlers, count } = await loadAbilitiesAsTools(stub); + TestAssert.equal(count, 1, 'collision must not produce two tools'); + TestAssert.equal(definitions[0].description, 'first claimant'); + await handlers.gv_views_list({}); + const run = stub.requests.find((r) => typeof r.url === 'string' && r.url.includes('/run')); + TestAssert.equal(run.url, '/wp-json/wp-abilities/v1/abilities/gk-gravityview/views-list/run', 'handler must stay bound to the first claimant'); +}); + +suite.test('empty catalog → falls back to WP core path', async () => { + const stub = buildCatalogStubGvClient([[]], { coreCatalog: syntheticCatalog() }); + const { source, count } = await loadAbilitiesAsTools(stub); + TestAssert.equal(source, 'wp-core'); + TestAssert.equal(count, 3); }); suite.test('loadAbilitiesAsTools: EVERY generated tool has a valid MCP inputSchema', async () => { @@ -213,8 +399,8 @@ suite.test('loadAbilitiesAsTools: EVERY generated tool has a valid MCP inputSche suite.test('loadAbilitiesAsTools: tools 29-36 repro — array input_schema is wrapped', async () => { const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); - const tool = definitions.find((d) => d.name === 'gv_add_view_field'); - TestAssert.isTrue(!!tool, 'gv_add_view_field must exist'); + const tool = definitions.find((d) => d.name === 'gv_view_field_add'); + TestAssert.isTrue(!!tool, 'gv_view_field_add must exist'); assertValidMcpInputSchema(tool.inputSchema); TestAssert.isTrue('view_id' in tool.inputSchema.properties); TestAssert.isTrue('field_id' in tool.inputSchema.properties); @@ -223,15 +409,15 @@ suite.test('loadAbilitiesAsTools: tools 29-36 repro — array input_schema is wr suite.test('loadAbilitiesAsTools: tool 57 repro — properties:[] becomes properties:{}', async () => { const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); - const tool = definitions.find((d) => d.name === 'gv_list_joins'); - TestAssert.isTrue(!!tool, 'gv_list_joins must exist'); + const tool = definitions.find((d) => d.name === 'gk_list_joins'); + TestAssert.isTrue(!!tool, 'gk_list_joins must exist'); assertValidMcpInputSchema(tool.inputSchema); TestAssert.deepEqual(tool.inputSchema.properties, {}); }); suite.test('loadAbilitiesAsTools: healthy schema passes through untouched', async () => { const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); - const tool = definitions.find((d) => d.name === 'gv_list_layouts'); + const tool = definitions.find((d) => d.name === 'gv_layouts_list'); TestAssert.isTrue(!!tool); TestAssert.deepEqual(tool.inputSchema.properties, { compact: { type: 'boolean' } }); }); @@ -241,12 +427,6 @@ suite.test('loadAbilitiesAsTools: healthy schema passes through untouched', asyn // regressions here would silently mis-route every gv_* call. // --------------------------------------------------------------------------- -suite.test('abilityNameToToolName: strips namespace, kebab → snake', () => { - TestAssert.equal(abilityNameToToolName('gk-gravityview/list-layouts'), 'gv_list_layouts'); - TestAssert.equal(abilityNameToToolName('gk-multiple-forms/list-joins'), 'gv_list_joins'); - TestAssert.equal(abilityNameToToolName('gk-gravityview/apply-view-config'), 'gv_apply_view_config'); -}); - suite.test('methodForAbility: readonly → GET, destructive → DELETE, else POST', () => { TestAssert.equal(methodForAbility({ readonly: true }), 'GET'); TestAssert.equal(methodForAbility({ destructive: true }), 'DELETE'); diff --git a/src/view-operations/abilities-loader.js b/src/view-operations/abilities-loader.js index 0613725..1b24b51 100644 --- a/src/view-operations/abilities-loader.js +++ b/src/view-operations/abilities-loader.js @@ -1,44 +1,47 @@ /** * Auto-generate MCP tool definitions from the live WordPress - * Abilities API catalog (`/wp-json/wp-abilities/v1/abilities`). + * Abilities API surface. * - * Replaces the hand-maintained `viewToolDefinitions` array (and the - * corresponding `buildViewToolHandlers` switch) with a dynamic - * pipeline: + * Source preference chain (each step falls back to the next): * - * 1. On MCP startup, fetch every ability registered under the - * `gk-gravityview/` namespace. - * 2. Transform each ability into a `{ name, description, inputSchema }` - * tuple the MCP runtime can list to clients. - * 3. Build a handler per ability that executes the ability through - * `/wp-abilities/v1/abilities/{name}/run` with the right HTTP - * method derived from the ability's annotations - * (`readonly` → GET, `destructive` → DELETE, otherwise POST). + * 1. Foundation catalog — `/wp-json/gravitykit/v1/abilities`. + * The canonical contract: server-side GravityKit filtering + * (`gk_registered_by === 'gravitykit'`), server-owned tool naming + * (`mcp_tool_name`, derived from Foundation's per-product + * MCP_TOOL_PREFIXES map), and disabled abilities already omitted. + * Any GravityKit product that registers abilities through Foundation + * appears here automatically — no client-side allow-list. + * 2. WP core catalog — `/wp-json/wp-abilities/v1/abilities`. + * For connections whose user can't pass the Foundation catalog's + * permission gate (default manage_options vs core's `read`). + * Filtered client-side on Foundation's stamped metadata: + * `meta.gk_registered_by === 'gravitykit'`. + * 3. When both catalogs are unreachable (older WP without the + * Abilities API, plugin disabled, network blip) this module throws; + * the caller leaves gv_* tools unregistered and retries on the next + * gv_* call (self-healing). * - * Naming convention: `gk-gravityview/list-layouts` → `gv_list_layouts`. - * Strips the namespace prefix and converts dashes to underscores. + * Tool naming is owned by the SERVER on both paths: Foundation's + * `mcp_tool_name` (Manager::get_mcp_tool_name() + MCP_TOOL_PREFIXES). + * The client never invents names — abilities arriving without + * `mcp_tool_name` are skipped with a warning, so a naming gap is + * visible instead of silently diverging between connections. * - * When the abilities catalog is unreachable (older WP without the - * Abilities API, plugin disabled, network blip), the caller falls - * back to the legacy hand-maintained tool definitions. + * Handlers execute abilities through `/wp-abilities/v1/abilities/{name}/run` + * with the HTTP method derived from the ability's annotations + * (`readonly` → GET, `destructive` → DELETE, otherwise POST). */ -/** - * Convert a fully-qualified ability name to the MCP tool name our - * existing callers + docs already know. Idempotent. - * - * Examples: - * gk-gravityview/list-layouts → gv_list_layouts - * gk-gravityview/apply-view-config → gv_apply_view_config - * gk-gravityview/get-template-settings-schema → gv_get_template_settings_schema - */ -export function abilityNameToToolName(abilityName) { - if (typeof abilityName !== 'string' || !abilityName.includes('/')) { - return abilityName; - } - const [, slug] = abilityName.split('/'); - return 'gv_' + slug.replace(/-/g, '_'); -} +import logger from '../utils/logger.js'; + +/** Foundation's GravityKit-only catalog route (Foundation >= 1.21). */ +export const FOUNDATION_CATALOG_ROUTE = '/wp-json/gravitykit/v1/abilities'; + +/** WP core's all-plugins abilities route (WP 6.9+ / abilities-api). */ +export const CORE_ABILITIES_ROUTE = '/wp-json/wp-abilities/v1/abilities'; + +/** Foundation's ability-name contract: gk-{product}/{action}. */ +const GK_NAME_PATTERN = /^gk-[a-z0-9-]+\//; /** * Determine the HTTP method to use when executing an ability. @@ -149,57 +152,191 @@ function arrayToProperties(arr) { } /** - * Default ability namespaces surfaced as MCP tools. Both `gk-gravityview/*` - * (core GravityView abilities) and `gk-multiple-forms/*` (the Multiple - * Forms add-on's join surface) share the `gv_*` MCP prefix because - * they're conceptually one product family from the agent's POV. Slugs - * are unique across both namespaces (verified manually); a collision - * would silently shadow one with the other and is worth detecting if - * we add a third namespace. + * Fetch the abilities surface + build MCP tool definitions and handlers. * - * @type {string[]} + * Tries the Foundation catalog first (canonical naming + filtering), + * falls back to the WP core catalog. Throws only when BOTH are + * unreachable — the caller leaves gv_* tools unregistered and retries + * on a later call. + * + * @param {object} gvClient GravityViewClient instance — uses its + * authenticated httpClient. + * @returns {Promise<{ definitions: object[], handlers: Record, count: number, source: 'foundation-catalog'|'wp-core' }>} */ -export const DEFAULT_ABILITY_NAMESPACES = ['gk-gravityview', 'gk-multiple-forms']; +export async function loadAbilitiesAsTools(gvClient) { + try { + const items = await fetchFoundationCatalogItems(gvClient); + const entries = catalogItemsToEntries(items); + + if (entries.length > 0) { + return buildTools(gvClient, entries, 'foundation-catalog'); + } + + logger.warn(`Foundation catalog at ${FOUNDATION_CATALOG_ROUTE} returned no usable abilities — falling back to WP core catalog`); + } catch (err) { + logger.warn(`Foundation catalog unavailable (${err.message}) — falling back to WP core catalog at ${CORE_ABILITIES_ROUTE}`); + } + + const entries = await fetchCoreEntries(gvClient); + return buildTools(gvClient, entries, 'wp-core'); +} /** - * Fetch the abilities catalog + build MCP tool definitions and - * handlers in a single pass. + * Fetch every page of the Foundation GravityKit catalog. * - * Throws when the abilities catalog endpoint is unreachable (the - * caller decides whether to fall back to legacy tool defs). + * Pagination per the Foundation contract: `page`/`per_page` params, + * `X-WP-TotalPages` response header. MAX_PAGES is a runaway guard, not + * a coverage cap — at 100 items/page it allows 2,000 abilities. * - * @param {object} gvClient GravityViewClient instance — uses its - * authenticated httpClient. - * @param {string|string[]} [namespaces=DEFAULT_ABILITY_NAMESPACES] - * Filter abilities by namespace prefix. Accepts a single string for - * backward compatibility or an array. - * @returns {Promise<{ definitions: object[], handlers: Record, count: number }>} + * @param {object} gvClient GravityViewClient instance. + * @returns {Promise} Catalog items (Manager::to_rest_item() shape). + */ +async function fetchFoundationCatalogItems(gvClient) { + const PER_PAGE = 100; + const MAX_PAGES = 20; + const items = []; + + let page = 1; + let totalPages = 1; + + do { + // gvClient.httpClient is namespaced to /gravityview/v1 — override + // baseURL per-request so the URL resolves at the WP root (same + // auth + TLS config, no second axios instance). + const response = await gvClient.httpClient.request({ + method: 'GET', + baseURL: gvClient.baseUrl, + url: FOUNDATION_CATALOG_ROUTE, + params: { per_page: PER_PAGE, page }, + }); + + if (!Array.isArray(response.data)) { + throw new Error('Unexpected Foundation catalog shape — expected array.'); + } + + items.push(...response.data); + + const headerTotal = Number(response.headers?.['x-wp-totalpages']); + totalPages = Number.isFinite(headerTotal) && headerTotal > 0 ? Math.min(headerTotal, MAX_PAGES) : 1; + page += 1; + } while (page <= totalPages); + + return items; +} + +/** + * Map Foundation catalog items (Manager::to_rest_item() shape) to the + * internal tool-entry shape. The catalog is already GravityKit-only and + * omits disabled abilities by default; the name-pattern and `enabled` + * checks here are defensive only. Items without `mcp_tool_name` are + * skipped — the server owns naming, the client never derives. + * + * @param {object[]} items Foundation catalog items. + * @returns {Array<{abilityName: string, toolName: string, description: string, rawInputSchema: unknown, annotations: object}>} + */ +function catalogItemsToEntries(items) { + const entries = []; + + for (const item of items) { + if (typeof item?.name !== 'string' || !GK_NAME_PATTERN.test(item.name)) continue; + if (item.enabled === false) continue; + if (typeof item.mcp_tool_name !== 'string' || item.mcp_tool_name === '') { + logger.warn(`Ability ${item.name} has no mcp_tool_name — skipped (the server owns tool naming)`); + continue; + } + + entries.push({ + abilityName: item.name, + toolName: item.mcp_tool_name, + description: item.description || item.label || item.name, + rawInputSchema: item.input_schema, + annotations: item.annotations && typeof item.annotations === 'object' ? item.annotations : {}, + }); + } + + return entries; +} + +/** + * Fetch the WP core abilities catalog and filter to GravityKit abilities. + * + * Filters on Foundation's stamped metadata + * (`meta.gk_registered_by === 'gravitykit'`) — the documented + * cross-product contract ("filter on these keys rather than parsing + * names"). Naming requires `meta.mcp_tool_name`; abilities without it + * are skipped with a warning (the server owns naming). + * + * Throws when no usable abilities are found so the caller's state stays + * null (not sticky-empty) and the per-call self-heal keeps retrying. + * + * @param {object} gvClient GravityViewClient instance. + * @returns {Promise>} */ -export async function loadAbilitiesAsTools(gvClient, namespaces = DEFAULT_ABILITY_NAMESPACES) { - // gvClient.httpClient is namespaced to /gravityview/v1. The - // Abilities API lives at a sibling namespace (/wp-abilities/v1), - // so we override baseURL per-request to the WP root rather than - // creating a second axios instance (same auth + TLS config). +async function fetchCoreEntries(gvClient) { const { data } = await gvClient.httpClient.request({ method: 'GET', baseURL: gvClient.baseUrl, - url: '/wp-json/wp-abilities/v1/abilities', + url: CORE_ABILITIES_ROUTE, }); + if (!Array.isArray(data)) { throw new Error('Unexpected Abilities API catalog shape — expected array.'); } - const nsList = Array.isArray(namespaces) ? namespaces : [namespaces]; - const ours = data.filter( - (a) => typeof a?.name === 'string' && nsList.some((ns) => a.name.startsWith(ns + '/')), - ); + const entries = []; + + for (const ability of data) { + if (typeof ability?.name !== 'string') continue; + const meta = ability.meta && typeof ability.meta === 'object' ? ability.meta : {}; + if (meta.gk_registered_by !== 'gravitykit') continue; + + if (typeof meta.mcp_tool_name !== 'string' || meta.mcp_tool_name === '') { + logger.warn(`Ability ${ability.name} has no meta.mcp_tool_name — skipped (the server owns tool naming)`); + continue; + } + + entries.push({ + abilityName: ability.name, + toolName: meta.mcp_tool_name, + description: ability.description || ability.label || ability.name, + rawInputSchema: ability.input_schema, + annotations: meta.annotations && typeof meta.annotations === 'object' ? meta.annotations : {}, + }); + } + + if (entries.length === 0) { + throw new Error('No usable GravityKit abilities in the WP core catalog (missing gk_registered_by stamp or mcp_tool_name).'); + } + + return entries; +} + +/** + * Build MCP tool definitions + handlers from normalized entries. + * + * Collision guard: with naming delegated to the server and filtering no + * longer namespace-bound, two abilities could map to one tool name. The + * first wins; later collisions are logged and skipped — never silently + * shadowed. + * + * @param {object} gvClient GravityViewClient instance. + * @param {Array} entries Normalized tool entries. + * @param {string} source Which catalog produced the entries. + * @returns {{ definitions: object[], handlers: Record, count: number, source: string }} + */ +function buildTools(gvClient, entries, source) { const definitions = []; const handlers = {}; + const claimedBy = new Map(); - for (const ability of ours) { - const toolName = abilityNameToToolName(ability.name); - const annotations = ability?.meta?.annotations || {}; + for (const entry of entries) { + const existing = claimedBy.get(entry.toolName); + if (existing) { + logger.warn(`Tool-name collision: "${entry.toolName}" from ${entry.abilityName} clashes with ${existing} — skipping ${entry.abilityName}`); + continue; + } + claimedBy.set(entry.toolName, entry.abilityName); // MCP tool definition. `normalizeInputSchema()` guarantees the // shape MCP's Zod validator expects: @@ -208,32 +345,29 @@ export async function loadAbilitiesAsTools(gvClient, namespaces = DEFAULT_ABILIT // (top-level or under `properties`) fail `tools/list` validation — // see the helper's docblock for the two shapes we coerce. definitions.push({ - name: toolName, - description: ability.description || ability.label || ability.name, - inputSchema: normalizeInputSchema(ability.input_schema), + name: entry.toolName, + description: entry.description, + inputSchema: normalizeInputSchema(entry.rawInputSchema), }); // Closure captures the ability name + method so the dispatcher - // doesn't need to re-resolve them at call time. The server-side - // ability registry no longer exposes any "delete the whole View" - // ability — the most destructive surface left is removing a - // single field/widget/row, which is part of normal authoring - // (and reversible by re-adding). For status-level changes the - // caller uses gv_set_view_status with status='trash', gated by - // delete_post on the WP side. - const abilityName = ability.name; - const method = methodForAbility(annotations); - handlers[toolName] = async (params) => executeAbility(gvClient, abilityName, method, params || {}); + // doesn't need to re-resolve them at call time. Destructive + // gating lives server-side: each ability's permission_callback + // (e.g. delete_post for view-delete) plus Foundation's + // per-ability enable/disable toggles. + const abilityName = entry.abilityName; + const method = methodForAbility(entry.annotations); + handlers[entry.toolName] = async (params) => executeAbility(gvClient, abilityName, method, params || {}); } - return { definitions, handlers, count: ours.length }; + return { definitions, handlers, count: definitions.length, source }; } /** * Execute one ability via `/wp-abilities/v1/abilities/{name}/run`. * * Encoding rules per the Abilities API spec: - * - GET / DELETE: input rides on a `?input=` query arg + * - GET / DELETE: input rides on bracketed query params * - POST: input rides in the JSON body as `{input: ...}` * * Errors propagate verbatim from the server so the MCP runtime can diff --git a/src/view-operations/index.js b/src/view-operations/index.js deleted file mode 100644 index 254a419..0000000 --- a/src/view-operations/index.js +++ /dev/null @@ -1,522 +0,0 @@ -/** - * GravityView Inspector tool surface for the GravityKit MCP. - * - * Exports: - * - createViewOperations(client) → { manager, validator } - * - viewToolDefinitions → JSON Schema for every gv_* tool - * - viewToolHandlers → { tool_name: async (params, ctx) => result } - * - * Handlers run client-side validation BEFORE calling the REST API - * so structural mistakes fail with a useful error instead of a 400. - * Schema-aware validation is gated on `validateAgainstSchemas: true` - * because each unique field type costs one network round trip. - */ - -import { ViewValidator } from './view-validator.js'; - -// Shared compact arg used by every tool — mirrors the gf_* convention. -const COMPACT_ARG = { - compact: { type: 'boolean', description: 'Set false for full raw response data', default: true }, -}; - -// Reusable arg shapes. -const VIEW_ID = { type: 'integer', description: 'GravityView post id' }; -const AREA = { - type: 'string', - description: 'Area key in the form `{zone}_{areaid}` (e.g. directory_list-title). Layout Builder templates append `::cols::row_uid` for compound keys.', -}; -const SLOT = { - type: 'string', - description: 'Slot UID (UUID v4 for new slots; legacy slots may use 13-char MD5 hex).', -}; -const IF_MATCH = { - type: 'string', - description: 'Optional optimistic-concurrency token. Pass the `version` from a previous read, or "auto" to use the client-cached version. Server returns 412 on stale.', -}; - -export function createViewOperations(client) { - const validator = new ViewValidator(client); - return { client, validator }; -} - -export const viewToolDefinitions = [ - // -------------------------------------------------------------- Discovery - { - name: 'gv_list_layouts', - description: 'List the installed GravityView layout engines (Layout Builder, DIY, Table, List, DataTables, Map, …) with id / label / description / logo / has_grid. Use to discover valid template_id values before creating or switching a View. Excludes legacy `preset_*` content presets and inactive add-on placeholders. `has_grid: true` means the layout drives placement via `POST /views/{id}/grid/_rows`; otherwise the layout exposes static areas (see gv_get_view_areas).', - inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, - }, - { - name: 'gv_get_template_settings_schema', - description: 'Per-template settings catalogue. Returns the full set of `template_settings` keys a layout supports — `page_size` / `sort_field` for any template, plus layout-specific settings (e.g. `map_zoom`, `map_type`, `map_layers` for the Maps layout; `datatables.responsive`, `datatables.rowgroup_field` for DataTables when the extension is installed). Discovered live via the `gk/gravityview/rest/template-settings/sources` filter so any add-on with its own silo meta key surfaces automatically. Dotted slugs (e.g. `datatables.responsive`) round-trip through PATCH /template-settings + apply.template_settings to the right meta key.', - inputSchema: { - type: 'object', - properties: { - template_id: { type: 'string', description: 'Layout template id (use gv_list_layouts to discover).' }, - ...COMPACT_ARG, - }, - required: ['template_id'], - }, - }, - { - name: 'gv_list_widgets', - description: 'List every registered GravityView widget (id, label, description, icon, class). Use to discover valid widget ids before placing one with gv_add_view_widget. Sourced live from \\GV\\Widget::registered() so third-party widgets surface automatically.', - inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, - }, - { - name: 'gv_list_grid_row_types', - description: 'List every registered Layout Builder row type (100, 50/50, 33/66, 33/33/33, 25/25/25/25, …) with their column structure. Use to discover valid `type` values before calling gv_create_grid_row. Sourced live from \\GV\\Grid::get_row_types() so add-on row layouts surface automatically.', - inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, - }, - { - name: 'gv_list_widget_zones', - description: 'List the widget meta-zones (header, footer). Use as the `zones` param for surface=widgets grid CRUD. Note: visible zone names like "header_top" / "header_left" are zone+column combinations — pick the meta-zone here, then the row type determines the columns.', - inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, - }, - { - name: 'gv_list_search_zones', - description: 'List the Search Bar internal zones (search-general, search-advanced). Filterable via `gk/gravityview/rest/search-zones` for add-ons.', - inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, - }, - { - name: 'gv_list_view_forms', - description: 'List Gravity Forms forms exposed via the GravityView Inspector REST surface. Lighter than gf_list_forms (returns just id/title/fields count). For full form details use gf_get_form.', - inputSchema: { type: 'object', properties: { ...COMPACT_ARG } }, - }, - { - name: 'gv_get_field_type_schema', - description: 'Get the settings schema for a GravityView field type (e.g. "custom", "entry_link", "text"). No View id required — useful for AI agents authoring a fresh View.\n\nLayout templates can add or remove settings on top of the core field schema (e.g. DIY adds a Container Tag picker, others may add their own settings). Pass `template_id` to fetch the overlay schema the chosen layout exposes; each schema item\'s `slug` is the setting key to use when building apply payloads. Run gv_list_layouts to discover which layouts are installed and inspect their settings_overlay descriptors.', - inputSchema: { - type: 'object', - properties: { - field_type: { type: 'string', description: 'Field type slug (e.g. "custom", "entry_link"). For form-bound fields (numeric ids), use gv_get_view_field_schemas instead.' }, - template_id: { type: 'string', description: 'Optional layout template id; affects which settings the schema includes. Defaults to default_list. Different layouts overlay different setting sets — gv_list_layouts surfaces what each layout adds.' }, - context: { type: 'string', enum: ['multiple', 'single', 'edit', 'search'], description: 'Render context. Defaults to multiple.' }, - input_type: { type: 'string', description: 'Optional GF input type (textarea, select, …) for form-bound fields.' }, - form_id: { type: 'integer', description: 'Optional form id for input-type detection.' }, - ...COMPACT_ARG, - }, - required: ['field_type'], - }, - }, - - // --------------------------------------------------------------- Per-view reads - { - name: 'gv_get_view_config', - description: 'Read the full View configuration tree (template, fields, widgets, template_settings, search_criteria, version). Returns the version that subsequent writes can pass via ifMatch.', - inputSchema: { type: 'object', properties: { id: VIEW_ID, ...COMPACT_ARG }, required: ['id'] }, - }, - { - name: 'gv_get_view_areas', - description: 'Inventory of zones (directory / single / edit) and area ids the View\'s template exposes. Tells you the valid `area` keys for adding/moving fields.', - inputSchema: { type: 'object', properties: { id: VIEW_ID, ...COMPACT_ARG }, required: ['id'] }, - }, - { - name: 'gv_list_available_fields', - description: 'Form fields placeable into the View, plus GravityView meta-fields (custom content, entry link, edit link, etc.). Use the returned ids as `field_id` when adding fields.', - inputSchema: { type: 'object', properties: { id: VIEW_ID, ...COMPACT_ARG }, required: ['id'] }, - }, - { - name: 'gv_get_view_field_schemas', - description: 'Bulk schema for every CONFIGURED slot in the View — `{area/slot}` → settings schema. One call instead of N gv_get_field_type_schema calls.', - inputSchema: { type: 'object', properties: { id: VIEW_ID, ...COMPACT_ARG }, required: ['id'] }, - }, - { - name: 'gv_render_view_field', - description: 'Render a single slot to HTML. Two staged-preview modes (server renders in-memory, persists nothing): pass `settings` to override an EXISTING saved slot, OR pass `staged_slot: { field_id, label?, ...settings }` together with a freshly-minted `slot` UID to render a brand-new (unsaved) slot the user just dragged into the layout. Without `staged_slot`, an unknown `slot` returns 404.', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - area: AREA, - slot: SLOT, - settings: { type: 'object', description: 'Optional staged settings overrides on a saved slot. Persists nothing.' }, - staged_slot: { type: 'object', description: 'Optional synthesized slot for unsaved-slot preview: `{ field_id, label?, ...settings }`. Use when the URL `slot` doesn\'t yet exist in storage (e.g. immediately after drag-in, before apply).' }, - ...COMPACT_ARG, - }, - required: ['id', 'area', 'slot'], - }, - }, - - // --------------------------------------------------------------- Create - { - name: 'gv_create_view', - description: 'Create a draft GravityView View, optionally seeded with template settings + fields + widgets in one shot. Returns the full config envelope (including `view_id`, `version`, and `admin_url`) so no follow-up GET is needed.', - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'View title (post title).' }, - form_id: { type: 'integer', description: 'Source Gravity Forms form id. Use gf_list_forms to discover.' }, - template_id: { type: 'string', description: 'Layout template for the directory zone (Multiple Entries listing). Defaults to gravityview-layout-builder. Use gv_list_layouts for the catalogue.' }, - template_ids: { - type: 'object', - description: 'Optional per-zone template overrides — { single?, edit? }. Multiple Entries (directory) and Single Entry can use different layouts (e.g. directory: gravityview-layout-builder, single: default_table). Single defaults to directory; edit follows directory unless explicitly set.', - }, - status: { type: 'string', enum: ['draft', 'publish', 'pending', 'private'], description: 'Initial post status. Defaults to draft.' }, - template_settings: { type: 'object', description: 'Optional initial template_settings (page_size, lightbox, etc.).' }, - search_criteria: { type: 'object', description: 'Optional initial search_criteria (sort, pagination defaults).' }, - fields: { type: 'object', description: 'Optional initial field tree, keyed by area key. Each value is an ordered array of `{ field_id, label?, slot?, …settings }` objects.' }, - widgets: { type: 'object', description: 'Optional initial widget tree (same shape as fields).' }, - mode: { type: 'string', enum: ['replace', 'merge'], description: 'Apply mode for fields/widgets. Default: replace.' }, - validateAgainstSchemas: { type: 'boolean', description: 'When true, fetches each referenced field type\'s schema and rejects unknown setting keys before sending. Costs extra round trips. Default: false.' }, - ...COMPACT_ARG, - }, - required: ['title', 'form_id'], - }, - }, - - // --------------------------------------------------------------- Bulk apply - { - name: 'gv_apply_view_config', - description: 'Bulk apply template + settings + ordered fields + widgets to an existing View in one round trip. Server-side mode: replace (default — each area in payload replaces existing area) or merge (additive). Pass ifMatch to enforce optimistic concurrency.', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - template_id: { type: 'string', description: 'Switch the directory-zone template before applying fields/widgets. Equivalent to gv_set_view_template (zone=directory) + the apply.' }, - template_ids: { - type: 'object', - description: 'Optional per-zone template overrides — { single?, edit? }. Same shape as gv_create_view. Pass an empty string for a zone to clear its override (falls back to directory).', - }, - template_settings: { type: 'object', description: 'Partial-merge into template_settings.' }, - search_criteria: { type: 'object', description: 'Pagination + sort defaults. Persisted into template_settings.' }, - fields: { type: 'object', description: 'Ordered field arrays per area key.' }, - widgets: { type: 'object', description: 'Ordered widget arrays per area key.' }, - mode: { type: 'string', enum: ['replace', 'merge'], description: 'replace = each area in fields/widgets replaces existing. merge = additive. Default: replace.' }, - ifMatch: IF_MATCH, - validateAgainstSchemas: { type: 'boolean', description: 'When true, fetches each referenced field type\'s schema and rejects unknown setting keys before sending. Default: false.' }, - ...COMPACT_ARG, - }, - required: ['id'], - }, - }, - - // --------------------------------------------------------------- Surgical settings + template - { - name: 'gv_set_view_template', - description: 'Switch a View zone\'s layout template. The directory zone (Multiple Entries) is the default; pass `zone: "single"` for Single Entry or `zone: "edit"` for Edit Entry. Discard policy controls whether the affected zone\'s existing field/widget placements survive the switch.', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - template_id: { type: 'string', description: 'New template id. Use gv_list_layouts to discover.' }, - zone: { type: 'string', enum: ['directory', 'single', 'edit'], description: 'Zone to switch. Defaults to directory.' }, - policy: { type: 'string', enum: ['discard', 'keep'], description: 'discard (default) clears the zone\'s field+widget placements so they don\'t reference the old template\'s areas; keep preserves them at the risk of orphan placements.' }, - ifMatch: IF_MATCH, - ...COMPACT_ARG, - }, - required: ['id', 'template_id'], - }, - }, - { - name: 'gv_patch_view_settings', - description: 'Partial-merge into template_settings (page_size, lightbox, show_only_approved, etc.). Other settings preserved.', - inputSchema: { - type: 'object', - properties: { id: VIEW_ID, template_settings: { type: 'object' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, - required: ['id', 'template_settings'], - }, - }, - { - name: 'gv_patch_view_search_criteria', - description: 'Partial-merge into search_criteria (sort_field, sort_direction, page_size). Persisted into template_settings.', - inputSchema: { - type: 'object', - properties: { id: VIEW_ID, search_criteria: { type: 'object' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, - required: ['id', 'search_criteria'], - }, - }, - - // --------------------------------------------------------------- Surgical field ops - { - name: 'gv_add_view_field', - description: 'Add a single field slot to an area. Server mints the slot UID and returns it. For multi-field changes prefer gv_apply_view_config with mode=merge.', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - area: AREA, - field: { - type: 'object', - description: '{ field_id (required), label?, custom_label?, …settings }', - required: ['field_id'], - }, - ifMatch: IF_MATCH, - ...COMPACT_ARG, - }, - required: ['id', 'area', 'field'], - }, - }, - { - name: 'gv_patch_view_field', - description: 'Patch a single field slot\'s settings. Only keys present in `settings` are updated; rest preserved. Use for cosmetic edits (custom_label, custom_class) and per-field options (show_label, only_loggedin, etc.).', - inputSchema: { - type: 'object', - properties: { id: VIEW_ID, area: AREA, slot: SLOT, settings: { type: 'object' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, - required: ['id', 'area', 'slot', 'settings'], - }, - }, - { - name: 'gv_move_view_field', - description: 'Move a field across areas, or reorder within the same area. The moved slot keeps its UID. Placement precedence in `to`: `before_slot` > `after_slot` > `position`. Use ref-relative placement ("place this field BEFORE the Title") instead of counting positions when possible — slot UIDs are stable across other moves.', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - from: { type: 'object', properties: { area: AREA, slot: SLOT }, required: ['area', 'slot'] }, - to: { - type: 'object', - properties: { - area: AREA, - before_slot: { type: 'string', description: 'Insert immediately BEFORE this slot UID (preferred when known).' }, - after_slot: { type: 'string', description: 'Insert immediately AFTER this slot UID.' }, - }, - required: ['area'], - }, - position: { description: 'Symbolic ("start" | "end") or zero-based integer. Defaults to "end". Negative integers append. Ignored when before_slot / after_slot is set.' }, - ifMatch: IF_MATCH, - ...COMPACT_ARG, - }, - required: ['id', 'from', 'to'], - }, - }, - { - name: 'gv_remove_view_field', - description: 'Delete a single field slot. Reversible — call gv_add_view_field with the same field_id to restore.', - inputSchema: { - type: 'object', - properties: { id: VIEW_ID, area: AREA, slot: SLOT, ifMatch: IF_MATCH, ...COMPACT_ARG }, - required: ['id', 'area', 'slot'], - }, - }, - - // --------------------------------------------------------------- Surgical widget ops - { - name: 'gv_add_view_widget', - description: 'Add a single widget slot to a widget area. Use gv_list_widgets to discover valid widget.field_id values (search_bar, page_links, page_info, custom_content, poll, gravityforms, etc.). For widget-specific settings, fetch gv_get_field_type_schema with the widget id (e.g. search_bar returns search_layout, search_fields, search_clear, search_mode, sieve_choices).', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - area: { type: 'string', description: 'Widget area key. Fixed list across templates: header_top, header_bottom, header_left, header_right, footer_top, footer_bottom, footer_left, footer_right.' }, - widget: { type: 'object', required: ['field_id'] }, - ifMatch: IF_MATCH, - ...COMPACT_ARG, - }, - required: ['id', 'area', 'widget'], - }, - }, - { - name: 'gv_patch_view_widget', - description: 'Patch a single widget slot\'s settings.', - inputSchema: { - type: 'object', - properties: { id: VIEW_ID, area: { type: 'string' }, slot: SLOT, settings: { type: 'object' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, - required: ['id', 'area', 'slot', 'settings'], - }, - }, - { - name: 'gv_remove_view_widget', - description: 'Delete a single widget slot. Reversible — call gv_add_view_widget to restore.', - inputSchema: { - type: 'object', - properties: { id: VIEW_ID, area: { type: 'string' }, slot: SLOT, ifMatch: IF_MATCH, ...COMPACT_ARG }, - required: ['id', 'area', 'slot'], - }, - }, - - // --------------------------------------------------------------- Grid (any surface) - { - name: 'gv_create_grid_row', - description: 'Add a grid row to one of the View\'s grid surfaces. surface=fields (default) targets the View\'s main field tree per zone (directory|single, prefixed by per-zone Layout Builder template). surface=widgets targets header / footer widget zones. Returns the new row_uid + materialised areaids per zone — use those areaids when placing fields/widgets via gv_apply_view_config. Use gv_list_grid_row_types for valid `type` values; gv_list_widget_zones for widget meta-zones.', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - surface: { type: 'string', enum: ['fields', 'widgets'], description: 'fields = View main field tree (default). widgets = header/footer widget zones.' }, - type: { type: 'string', description: 'Row type. Defaults to "100" (full width). Use gv_list_grid_row_types to discover the live catalogue (100, 50/50, 33/66, 33/33/33, 25/25/25/25, …).' }, - zones: { type: 'array', description: 'Zones to materialise the row in. surface=fields default: [directory, single]. surface=widgets default: [header, footer].', items: { type: 'string' } }, - ifMatch: IF_MATCH, - ...COMPACT_ARG, - }, - required: ['id'], - }, - }, - { - name: 'gv_patch_grid_row', - description: 'Re-key every field/widget in a grid row from one type to another (e.g. resize 100 → 50/50, or 33/33/33 → 50/50). When the new type has fewer columns, surplus items collapse into the first column rather than vanishing. Pass the same `surface` you used to create the row.', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - surface: { type: 'string', enum: ['fields', 'widgets'], description: 'Defaults to fields.' }, - row_uid: { type: 'string', description: 'Row UID returned by gv_create_grid_row or visible in gv_get_view_areas response.' }, - type: { type: 'string', description: 'New row type (see gv_list_grid_row_types).' }, - ifMatch: IF_MATCH, - ...COMPACT_ARG, - }, - required: ['id', 'row_uid', 'type'], - }, - }, - { - name: 'gv_delete_grid_row', - description: 'Remove a grid row and every field/widget placed in any of its areas on the targeted surface.', - inputSchema: { - type: 'object', - properties: { id: VIEW_ID, surface: { type: 'string', enum: ['fields', 'widgets'] }, row_uid: { type: 'string' }, ifMatch: IF_MATCH, ...COMPACT_ARG }, - required: ['id', 'row_uid'], - }, - }, - - // --------------------------------------------------------------- Search Bar internal slot CRUD (modern shape) - { - name: 'gv_add_search_field', - description: 'Add a Search Field inside a search_bar widget\'s modern search_fields_section. Identify the parent widget via widget_area + widget_slot (find them with gv_get_view_config; widget_area is a key under `widgets`, widget_slot is the search_bar\'s slot UID). The position string is `{search_zone}_{areaid}::{type}::{row_uid}` — get search_zone from gv_list_search_zones, build a row_uid + type via gv_create_grid_row (surface=widgets) if needed.\n\nfield.id options: "search_all" (free text), "submit", "search_mode", "created_by", "is_starred", "is_read", or a GF field id (e.g. "3"). field.input: input_text, select, multiselect, radio, checkbox, single_checkbox, date, date_range, number_range, link, hidden, submit. Use gv_get_field_type_schema with the search_field type for the full settings catalogue.', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - widget_area: { type: 'string', description: 'Widget area key (e.g. "header_top::100::ROW_UID").' }, - widget_slot: { type: 'string', description: 'Search bar widget\'s slot UID.' }, - position: { type: 'string', description: 'Search-bar internal position: `{search_zone}_{areaid}::{type}::{row_uid}`.' }, - field: { - type: 'object', - description: '{ id (required), type, input, label?, show_label?, ...settings }', - required: ['id'], - }, - slot: { type: 'string', description: 'Optional search slot UID. Auto-minted when omitted.' }, - ifMatch: IF_MATCH, - ...COMPACT_ARG, - }, - required: ['id', 'widget_area', 'widget_slot', 'position', 'field'], - }, - }, - { - name: 'gv_patch_search_field', - description: 'Patch settings on an existing search field slot. Settings keys present in the payload overwrite; null values delete that key.', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - widget_area: { type: 'string' }, - widget_slot: { type: 'string' }, - position: { type: 'string' }, - search_slot: { type: 'string' }, - settings: { type: 'object' }, - ifMatch: IF_MATCH, - ...COMPACT_ARG, - }, - required: ['id', 'widget_area', 'widget_slot', 'position', 'search_slot', 'settings'], - }, - }, - { - name: 'gv_remove_search_field', - description: 'Remove a search field slot from a search_bar widget\'s search_fields_section.', - inputSchema: { - type: 'object', - properties: { - id: VIEW_ID, - widget_area: { type: 'string' }, - widget_slot: { type: 'string' }, - position: { type: 'string' }, - search_slot: { type: 'string' }, - ifMatch: IF_MATCH, - ...COMPACT_ARG, - }, - required: ['id', 'widget_area', 'widget_slot', 'position', 'search_slot'], - }, - }, -]; - -/** - * Build the handler map. Returns `{ tool_name: async (params) => raw_result }`. - * The MCP transport layer handles compaction + content envelope wrapping. - */ -export function buildViewToolHandlers({ client, validator }) { - return { - // Discovery - gv_list_layouts: () => client.listLayouts(), - gv_get_template_settings_schema: (params) => client.getTemplateSettingsSchema(params), - gv_list_widgets: () => client.listWidgets(), - gv_list_grid_row_types: () => client.listGridRowTypes(), - gv_list_widget_zones: () => client.listWidgetZones(), - gv_list_search_zones: () => client.listSearchZones(), - gv_list_view_forms: () => client.listForms(), - gv_get_field_type_schema: (params) => client.getFieldTypeSchema(params), - - // Reads - gv_get_view_config: (params) => client.getViewConfig(params), - gv_get_view_areas: (params) => client.getViewAreas(params), - gv_list_available_fields: (params) => client.listAvailableFields(params), - gv_get_view_field_schemas: (params) => client.getViewFieldSchemas(params), - gv_render_view_field: (params) => client.renderViewField(params), - - // Create - gv_create_view: async (params) => { - validator.validateCreatePayload(params); - if (params.validateAgainstSchemas) { - await validator.validateAgainstSchemas({ - fields: params.fields || {}, - widgets: params.widgets || {}, - template_id: params.template_id, - }); - } - // Layout Builder area validation skipped on create — the View - // doesn't exist yet, so its grid hasn't been materialised. - const { validateAgainstSchemas, compact, ...payload } = params; - return client.createView(payload); - }, - - // Bulk apply - gv_apply_view_config: async (params) => { - validator.validateApplyPayload(params); - if (params.validateAgainstSchemas) { - await validator.validateAgainstSchemas({ - id: params.id, - fields: params.fields || {}, - widgets: params.widgets || {}, - template_id: params.template_id, - }); - } - // Layout Builder area validation: when any of the area keys - // contain `::` (the Layout Builder compound form), confirm - // they exist in the View's current grid before sending. - // Cheap (one extra GET) and saves a 400 round trip on typos. - await validator.validateLayoutBuilderAreas({ - id: params.id, - fields: params.fields || {}, - widgets: params.widgets || {}, - }); - const { validateAgainstSchemas, compact, ...payload } = params; - return client.applyViewConfig(payload); - }, - - // Surgical settings + template - gv_set_view_template: (params) => client.setViewTemplate(params), - gv_patch_view_settings: (params) => client.patchViewSettings(params), - gv_patch_view_search_criteria: (params) => client.patchViewSearchCriteria(params), - - // Surgical field ops - gv_add_view_field: (params) => client.addViewField(params), - gv_patch_view_field: (params) => client.patchViewField(params), - gv_move_view_field: (params) => client.moveViewField(params), - gv_remove_view_field: (params) => client.removeViewField(params), - - // Surgical widget ops - gv_add_view_widget: (params) => client.addViewWidget(params), - gv_patch_view_widget: (params) => client.patchViewWidget(params), - gv_remove_view_widget: (params) => client.removeViewWidget(params), - - // Grid CRUD (any surface — fields | widgets) - gv_create_grid_row: (params) => client.addGridRow(params), - gv_patch_grid_row: (params) => client.patchGridRow(params), - gv_delete_grid_row: (params) => client.deleteGridRow(params), - - // Search Bar internal slot CRUD (modern shape) - gv_add_search_field: (params) => client.addSearchField(params), - gv_patch_search_field: (params) => client.patchSearchField(params), - gv_remove_search_field: (params) => client.removeSearchField(params), - }; -} - -export { ViewValidator }; From b59a54131533ae0abc9302e470f69c6df3e7dde5 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 11 Jun 2026 15:32:29 -0400 Subject: [PATCH 08/36] =?UTF-8?q?refactor:=20scalable=20structure=20?= =?UTF-8?q?=E2=80=94=20product-agnostic=20abilities=20+=20WP=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/abilities/loader.js (was src/view-operations/abilities-loader.js) The dynamic tool pipeline is product-agnostic — it serves every GravityKit product from the Foundation catalog, so it no longer lives under a GravityView-named folder. src/wp-client.js (new) WordPressClient Extracted the authenticated WP-root transport (base URL, app-password auth, TLS, timeout) that the abilities loader rides. Runtime no longer constructs anything GravityView-specific. src/gravityview/ (product-specific test harness) inspector-client.js GravityViewInspectorClient extends WordPressClient, mounts /gravityview/v1. Test/demo harness only — the Inspector routes are registered server-side solely under DOING_GRAVITYVIEW_TESTS. Also fixed three stale ability slugs (available-fields-get, field-type-schema-get, search-input-types-list). view-validator.js moved alongside its product harness. Env vars renamed (never released): GRAVITYVIEW_BASE_URL → GRAVITYKIT_WP_URL, GRAVITYVIEW_WP_USERNAME/_APP_PASSWORD → GRAVITYKIT_WP_USERNAME/_APP_PASSWORD, GRAVITYVIEW_TIMEOUT → GRAVITYKIT_TIMEOUT. GF fallbacks unchanged. Documented in .env.example. Tests: 264 unit + 21 loader, all passing. --- .env.example | 13 ++- demo-abilities.mjs | 8 +- .../loader.js} | 48 +++++----- .../inspector-client.js} | 88 ++++-------------- .../view-validator.js | 0 src/index.js | 36 +++---- src/tests/abilities-loader.test.js | 2 +- src/tests/views-stress.test.js | 32 +++---- src/tests/views.test.js | 22 ++--- src/wp-client.js | 93 +++++++++++++++++++ 10 files changed, 198 insertions(+), 144 deletions(-) rename src/{view-operations/abilities-loader.js => abilities/loader.js} (92%) rename src/{gravityview-client.js => gravityview/inspector-client.js} (87%) rename src/{view-operations => gravityview}/view-validator.js (100%) create mode 100644 src/wp-client.js diff --git a/.env.example b/.env.example index 908e01c..3d59ca0 100644 --- a/.env.example +++ b/.env.example @@ -76,4 +76,15 @@ GRAVITY_FORMS_TEST_CONSUMER_SECRET=cs_test_secret_here # to their primary equivalents so the client connects to the test site. # GRAVITYKIT_MCP_TEST_MODE=true # Legacy name also supported: GRAVITYMCP_TEST_MODE=true -# Also activated when NODE_ENV=test \ No newline at end of file +# Also activated when NODE_ENV=test +# ============================================= +# GravityKit Abilities (gv_* and other product tools) +# ============================================= +# WordPress credentials for the abilities transport (Foundation catalog +# at /wp-json/gravitykit/v1 + WP core /wp-json/wp-abilities/v1). +# Optional — falls back to GRAVITY_FORMS_BASE_URL and the +# GRAVITY_FORMS_CONSUMER_KEY/SECRET pair when unset. +# GRAVITYKIT_WP_URL=https://your-site.com +# GRAVITYKIT_WP_USERNAME=admin +# GRAVITYKIT_WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx" +# GRAVITYKIT_TIMEOUT=30000 diff --git a/demo-abilities.mjs b/demo-abilities.mjs index bc7c4d7..41018fe 100644 --- a/demo-abilities.mjs +++ b/demo-abilities.mjs @@ -11,8 +11,8 @@ */ import 'dotenv/config'; -import { GravityViewClient } from '/Users/zackkatz/Dropbox/MonoKit/MCPs/gravitymcp/src/gravityview-client.js'; -import { loadAbilitiesAsTools, methodForAbility } from '/Users/zackkatz/Dropbox/MonoKit/MCPs/gravitymcp/src/view-operations/abilities-loader.js'; +import { WordPressClient } from '/Users/zackkatz/Dropbox/MonoKit/MCPs/gravitymcp/src/wp-client.js'; +import { loadAbilitiesAsTools, methodForAbility } from '/Users/zackkatz/Dropbox/MonoKit/MCPs/gravitymcp/src/abilities/loader.js'; const RESET = '\x1b[0m'; const DIM = '\x1b[2m'; @@ -39,7 +39,7 @@ function ok(s) { console.log(` ${GREEN}✓${RESET} ${s}`); } -const client = new GravityViewClient(process.env); +const client = new WordPressClient(process.env); // ────────────────────────────────────────────────────────────────── header('1. Discover the catalog (single network call)'); @@ -111,7 +111,7 @@ header('5. End-to-end round-trip: create → apply → read'); // ────────────────────────────────────────────────────────────────── step('5a', 'gv_create_view — mint a fresh draft'); -const formId = Number(process.env.GRAVITYVIEW_DEMO_FORM_ID || 296); +const formId = Number(process.env.GRAVITYKIT_DEMO_FORM_ID || 296); const created = await handlers.gv_create_view({ title: `Abilities API demo · ${new Date().toISOString().slice(11, 19)}`, form_id: formId, diff --git a/src/view-operations/abilities-loader.js b/src/abilities/loader.js similarity index 92% rename from src/view-operations/abilities-loader.js rename to src/abilities/loader.js index 1b24b51..6e1de28 100644 --- a/src/view-operations/abilities-loader.js +++ b/src/abilities/loader.js @@ -159,17 +159,17 @@ function arrayToProperties(arr) { * unreachable — the caller leaves gv_* tools unregistered and retries * on a later call. * - * @param {object} gvClient GravityViewClient instance — uses its + * @param {object} wpClient WordPressClient instance — uses its * authenticated httpClient. * @returns {Promise<{ definitions: object[], handlers: Record, count: number, source: 'foundation-catalog'|'wp-core' }>} */ -export async function loadAbilitiesAsTools(gvClient) { +export async function loadAbilitiesAsTools(wpClient) { try { - const items = await fetchFoundationCatalogItems(gvClient); + const items = await fetchFoundationCatalogItems(wpClient); const entries = catalogItemsToEntries(items); if (entries.length > 0) { - return buildTools(gvClient, entries, 'foundation-catalog'); + return buildTools(wpClient, entries, 'foundation-catalog'); } logger.warn(`Foundation catalog at ${FOUNDATION_CATALOG_ROUTE} returned no usable abilities — falling back to WP core catalog`); @@ -177,8 +177,8 @@ export async function loadAbilitiesAsTools(gvClient) { logger.warn(`Foundation catalog unavailable (${err.message}) — falling back to WP core catalog at ${CORE_ABILITIES_ROUTE}`); } - const entries = await fetchCoreEntries(gvClient); - return buildTools(gvClient, entries, 'wp-core'); + const entries = await fetchCoreEntries(wpClient); + return buildTools(wpClient, entries, 'wp-core'); } /** @@ -188,10 +188,10 @@ export async function loadAbilitiesAsTools(gvClient) { * `X-WP-TotalPages` response header. MAX_PAGES is a runaway guard, not * a coverage cap — at 100 items/page it allows 2,000 abilities. * - * @param {object} gvClient GravityViewClient instance. + * @param {object} wpClient WordPressClient instance. * @returns {Promise} Catalog items (Manager::to_rest_item() shape). */ -async function fetchFoundationCatalogItems(gvClient) { +async function fetchFoundationCatalogItems(wpClient) { const PER_PAGE = 100; const MAX_PAGES = 20; const items = []; @@ -200,12 +200,12 @@ async function fetchFoundationCatalogItems(gvClient) { let totalPages = 1; do { - // gvClient.httpClient is namespaced to /gravityview/v1 — override + // wpClient.httpClient is namespaced to /gravityview/v1 — override // baseURL per-request so the URL resolves at the WP root (same // auth + TLS config, no second axios instance). - const response = await gvClient.httpClient.request({ + const response = await wpClient.httpClient.request({ method: 'GET', - baseURL: gvClient.baseUrl, + baseURL: wpClient.baseUrl, url: FOUNDATION_CATALOG_ROUTE, params: { per_page: PER_PAGE, page }, }); @@ -269,13 +269,13 @@ function catalogItemsToEntries(items) { * Throws when no usable abilities are found so the caller's state stays * null (not sticky-empty) and the per-call self-heal keeps retrying. * - * @param {object} gvClient GravityViewClient instance. + * @param {object} wpClient WordPressClient instance. * @returns {Promise>} */ -async function fetchCoreEntries(gvClient) { - const { data } = await gvClient.httpClient.request({ +async function fetchCoreEntries(wpClient) { + const { data } = await wpClient.httpClient.request({ method: 'GET', - baseURL: gvClient.baseUrl, + baseURL: wpClient.baseUrl, url: CORE_ABILITIES_ROUTE, }); @@ -320,12 +320,12 @@ async function fetchCoreEntries(gvClient) { * first wins; later collisions are logged and skipped — never silently * shadowed. * - * @param {object} gvClient GravityViewClient instance. + * @param {object} wpClient WordPressClient instance. * @param {Array} entries Normalized tool entries. * @param {string} source Which catalog produced the entries. * @returns {{ definitions: object[], handlers: Record, count: number, source: string }} */ -function buildTools(gvClient, entries, source) { +function buildTools(wpClient, entries, source) { const definitions = []; const handlers = {}; const claimedBy = new Map(); @@ -357,7 +357,7 @@ function buildTools(gvClient, entries, source) { // per-ability enable/disable toggles. const abilityName = entry.abilityName; const method = methodForAbility(entry.annotations); - handlers[entry.toolName] = async (params) => executeAbility(gvClient, abilityName, method, params || {}); + handlers[entry.toolName] = async (params) => executeAbility(wpClient, abilityName, method, params || {}); } return { definitions, handlers, count: definitions.length, source }; @@ -396,10 +396,10 @@ function walkInputToBracketedParams(value, key, out) { out[key] = value; } -async function executeAbility(gvClient, abilityName, method, input) { - // Cross-namespace request — override baseURL away from - // /gravityview/v1 so the URL resolves to /wp-abilities/v1. - const baseURL = gvClient.baseUrl; +async function executeAbility(wpClient, abilityName, method, input) { + // Explicit baseURL so the URL resolves at the WP root regardless + // of how the client instance is namespaced. + const baseURL = wpClient.baseUrl; const url = `/wp-json/wp-abilities/v1/abilities/${abilityName}/run`; if (method === 'GET' || method === 'DELETE') { @@ -415,12 +415,12 @@ async function executeAbility(gvClient, abilityName, method, input) { walkInputToBracketedParams(input, 'input', params); config.params = params; } - const { data } = await gvClient.httpClient.request(config); + const { data } = await wpClient.httpClient.request(config); return data; } // POST. The Abilities API wraps input under an `input` key in the body. - const { data } = await gvClient.httpClient.request({ + const { data } = await wpClient.httpClient.request({ method, baseURL, url, diff --git a/src/gravityview-client.js b/src/gravityview/inspector-client.js similarity index 87% rename from src/gravityview-client.js rename to src/gravityview/inspector-client.js index f0672d2..7a0f764 100644 --- a/src/gravityview-client.js +++ b/src/gravityview/inspector-client.js @@ -3,15 +3,15 @@ * * Wraps the `/wp-json/gravityview/v1/...` endpoints exposed by the * GravityView plugin's Inspector route family (see - * `src/REST/InspectorRoute.php` in the GravityView codebase). + * `src/REST/InspectorRoute.php` in the GravityView codebase). Those + * routes are registered ONLY when `DOING_GRAVITYVIEW_TESTS` is defined + * server-side — this client is the integration-test and demo harness, + * not a runtime dependency. Runtime gv_* tools come from the abilities + * loader (`src/abilities/loader.js`) riding the base WordPressClient. * - * Authentication: WordPress Application Password via HTTP Basic Auth. - * The same WP install hosts both the GF REST and the GravityView REST - * surfaces, so when WP_USERNAME / WP_APP_PASSWORD aren't set we fall - * back to GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET - * (which in practice are usually a WP user + app password too — most - * local-dev setups reuse them rather than minting two separate - * credentials). + * Authentication, base-URL resolution, TLS, and timeouts come from + * WordPressClient (`src/wp-client.js`); this subclass mounts the + * gravityview/v1 namespace on top. * * Concurrency: every config write supports `If-Match: ""` * for optimistic-concurrency. Reads return the version in the body @@ -20,69 +20,19 @@ * { ifMatch: 'auto' })` without juggling ETags by hand. */ -import axios from 'axios'; -import https from 'https'; -import logger from './utils/logger.js'; +import logger from '../utils/logger.js'; +import { WordPressClient } from '../wp-client.js'; -export class GravityViewClient { +export class GravityViewInspectorClient extends WordPressClient { constructor(config) { - this.config = config || {}; + super(config); - const baseUrl = this.resolveBaseUrl(); - if (!baseUrl) { - throw new Error('GravityView client requires GRAVITYVIEW_BASE_URL or GRAVITY_FORMS_BASE_URL.'); - } - if (!baseUrl.startsWith('https://') && !baseUrl.startsWith('http://')) { - throw new Error('GravityView base URL must start with http:// or https://'); - } - - this.baseUrl = baseUrl.replace(/\/$/, ''); this.restNamespace = '/wp-json/gravityview/v1'; - - // Auth resolution order: canonical GRAVITYVIEW_* (prod-style) → - // WORDPRESS_LOCAL_DEV_TEST_* (the local dev.test admin creds; same - // values reused by any other MonoKit tool that hits the local - // install) → generic WP_USERNAME → GF MCP consumer key fallback. - // The descriptive local-dev names exist so this single admin - // credential isn't duplicated across every per-product env block. - const username = this.config.GRAVITYVIEW_WP_USERNAME - || this.config.WORDPRESS_LOCAL_DEV_TEST_ADMIN_USER - || this.config.WP_USERNAME - || this.config.GRAVITY_FORMS_CONSUMER_KEY; - const password = this.config.GRAVITYVIEW_WP_APP_PASSWORD - || this.config.WORDPRESS_LOCAL_DEV_TEST_ADMIN_PASSWORD - || this.config.WP_APP_PASSWORD - || this.config.GRAVITY_FORMS_CONSUMER_SECRET; - if (!username || !password) { - throw new Error('GravityView client requires WordPress credentials. Set GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD, or WORDPRESS_LOCAL_DEV_TEST_ADMIN_USER + _ADMIN_PASSWORD, or reuse GRAVITY_FORMS_CONSUMER_KEY/SECRET.'); - } - this.basicAuth = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); - - const allowSelfSigned = (this.config.GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS || this.config.MCP_ALLOW_SELF_SIGNED_CERTS) === 'true'; - - this.httpClient = axios.create({ - baseURL: `${this.baseUrl}${this.restNamespace}`, - timeout: parseInt(this.config.GRAVITYVIEW_TIMEOUT || this.config.GRAVITY_FORMS_TIMEOUT, 10) || 30000, - headers: { - 'User-Agent': 'GravityKit-MCP/2.1.1 (gravityview)', - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': this.basicAuth, - }, - httpsAgent: new https.Agent({ rejectUnauthorized: !allowSelfSigned }), - }); + this.httpClient = this.createHttpClient(`${this.baseUrl}${this.restNamespace}`); // version cache keyed by view id — populated by every read so // callers can opt into automatic If-Match without a manual GET. this.versionCache = new Map(); - - } - - resolveBaseUrl() { - return this.config.GRAVITYVIEW_BASE_URL - || this.config.WORDPRESS_LOCAL_DEV_TEST_URL - || this.config.GRAVITY_FORMS_BASE_URL - || ''; } /** @@ -160,7 +110,7 @@ export class GravityViewClient { * Canonical search-field input slugs. Used by the MCP validator * (and assertSearchInputType pre-flight) to reject typos. * - * Now delegates to the gk-gravityview/list-search-input-types + * Now delegates to the gk-gravityview/search-input-types-list * ability — the legacy `/gravityview/v1/search-fields/input-types` * route is gone post-Phase-5. */ @@ -168,7 +118,7 @@ export class GravityViewClient { const { data } = await this.httpClient.request({ method: 'GET', baseURL: this.baseUrl, - url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/list-search-input-types/run', + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/search-input-types-list/run', }); return data; } @@ -180,7 +130,7 @@ export class GravityViewClient { async getFieldTypeSchema({ field_type, template_id, context, input_type, form_id } = {}) { if (!field_type) throw new Error('field_type is required.'); - // Delegate to the gk-gravityview/get-field-type-schema ability. + // Delegate to the gk-gravityview/field-type-schema-get ability. // Bracketed query params are how WP REST rebuilds an object from // a query string — `?input[field_type]=text&input[template_id]=...`. const params = {}; @@ -190,7 +140,7 @@ export class GravityViewClient { const { data } = await this.httpClient.request({ method: 'GET', baseURL: this.baseUrl, - url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/get-field-type-schema/run', + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/field-type-schema-get/run', params, }); return data; @@ -218,7 +168,7 @@ export class GravityViewClient { const { data } = await this.httpClient.request({ method: 'GET', baseURL: this.baseUrl, - url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/list-available-fields/run', + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/available-fields-get/run', params: { 'input[id]': id }, }); return data; @@ -651,4 +601,4 @@ function stripUndefined(obj) { return out; } -export default GravityViewClient; +export default GravityViewInspectorClient; diff --git a/src/view-operations/view-validator.js b/src/gravityview/view-validator.js similarity index 100% rename from src/view-operations/view-validator.js rename to src/gravityview/view-validator.js diff --git a/src/index.js b/src/index.js index fac0cdd..9fbad5b 100644 --- a/src/index.js +++ b/src/index.js @@ -22,8 +22,8 @@ import FieldAwareValidator from './config/field-validation.js'; import logger from './utils/logger.js'; import { sanitize } from './utils/sanitize.js'; import { stripEmpty, stripEntryMetaFromResponse } from './utils/compact.js'; -import { GravityViewClient } from './gravityview-client.js'; -import { loadAbilitiesAsTools } from './view-operations/abilities-loader.js'; +import { WordPressClient } from './wp-client.js'; +import { loadAbilitiesAsTools } from './abilities/loader.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -52,7 +52,7 @@ const server = new Server( let gravityFormsClient = null; let fieldOperations = null; let fieldValidator = null; -let gravityViewClient = null; +let wpClient = null; // Auto-generated from the WordPress Abilities API (Foundation catalog // first, WP core fallback). Populated by initializeClient(). This is // the ONLY source of gv_* tools — when no catalog is reachable (older @@ -89,15 +89,15 @@ async function initializeClient() { logger.info('✅ GravityKit MCP initialized successfully'); logger.info('✅ Field operations infrastructure initialized'); - // GravityView Inspector client — separate WP REST namespace - // (`/wp-json/gravityview/v1/*`) so the credentials and base - // URL are resolved independently of the GF REST endpoint. The + // WordPress client — the authenticated transport to the WP root + // (Foundation catalog + WP core Abilities API). Credentials and + // base URL are resolved independently of the GF REST endpoint. The // constructor allows credential fallback to GRAVITY_FORMS_* // env vars so single-WP-install setups don't need to mint // two separate app passwords. try { - gravityViewClient = new GravityViewClient(process.env); - logger.info('✅ GravityView client initialized — loading gv_* abilities'); + wpClient = new WordPressClient(process.env); + logger.info('✅ WordPress client initialized — loading GravityKit abilities'); // Fire-and-forget: kick off the abilities catalog fetch in the // background so MCP startup is fast. ListTools awaits up to 2s @@ -109,8 +109,8 @@ async function initializeClient() { } catch (gvError) { // Don't fail the whole MCP if GravityView credentials are // missing — gf_* tools still work standalone. - logger.warn(`⚠️ GravityView client unavailable: ${gvError.message}`); - gravityViewClient = null; + logger.warn(`⚠️ WordPress client unavailable: ${gvError.message}`); + wpClient = null; abilityToolDefinitions = null; abilityToolHandlers = null; } @@ -141,7 +141,7 @@ async function initializeClient() { * after the timeout fires. */ async function ensureAbilitiesLoaded({ force = false, timeoutMs } = {}) { - if (!gravityViewClient) return; + if (!wpClient) return; if (force) { abilityToolDefinitions = null; abilityToolHandlers = null; @@ -149,7 +149,7 @@ async function ensureAbilitiesLoaded({ force = false, timeoutMs } = {}) { } if (abilityToolDefinitions) return; if (!abilitiesLoadPromise) { - abilitiesLoadPromise = loadAbilitiesAsTools(gravityViewClient) + abilitiesLoadPromise = loadAbilitiesAsTools(wpClient) .then(({ definitions, handlers, count, source }) => { abilityToolDefinitions = definitions; abilityToolHandlers = handlers; @@ -230,7 +230,7 @@ function wrapHandler(handler, params = {}) { /** * Variant of wrapHandler for gv_* tools. Differs in two ways: - * - Checks gravityViewClient (not gravityFormsClient). + * - Checks wpClient (not gravityFormsClient). * - Surfaces the inspector REST envelope (`{ code, message, data }`) * so the agent sees `gv_rest_invalid_template` etc. instead of a * generic "Request failed with status code 400". The inspector's @@ -238,7 +238,7 @@ function wrapHandler(handler, params = {}) { */ function wrapViewHandler(handler, params = {}) { return async () => { - if (!gravityViewClient) { + if (!wpClient) { return createErrorResponse('GravityView client not initialized'); } try { @@ -831,9 +831,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // (re)fetched. default: if (name === 'gv_reload_abilities') { - if (!gravityViewClient) { + if (!wpClient) { return createErrorResponse( - 'GravityView client not initialized. Set GRAVITYVIEW_BASE_URL + GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD in .env (or reuse the GRAVITY_FORMS_* credentials).' + 'WordPress client not initialized. Set GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD in .env (or reuse the GRAVITY_FORMS_* credentials).' ); } const before = abilityToolDefinitions?.length ?? 0; @@ -854,9 +854,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } if (typeof name === 'string' && name.startsWith('gv_')) { - if (!gravityViewClient) { + if (!wpClient) { return createErrorResponse( - 'GravityView client not initialized. Set GRAVITYVIEW_BASE_URL + GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD in .env (or reuse GRAVITY_FORMS_BASE_URL / GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET when the same WP install hosts both surfaces).' + 'WordPress client not initialized. Set GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD in .env (or reuse GRAVITY_FORMS_BASE_URL / GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET when the same WP install hosts both surfaces).' ); } // Self-heal: every gv_* call retries the abilities load if a diff --git a/src/tests/abilities-loader.test.js b/src/tests/abilities-loader.test.js index e8a45a2..3509db9 100644 --- a/src/tests/abilities-loader.test.js +++ b/src/tests/abilities-loader.test.js @@ -15,7 +15,7 @@ import { methodForAbility, FOUNDATION_CATALOG_ROUTE, CORE_ABILITIES_ROUTE, -} from '../view-operations/abilities-loader.js'; +} from '../abilities/loader.js'; const suite = new TestRunner('Abilities Loader Tests'); diff --git a/src/tests/views-stress.test.js b/src/tests/views-stress.test.js index ba5254f..b58d19c 100644 --- a/src/tests/views-stress.test.js +++ b/src/tests/views-stress.test.js @@ -36,8 +36,8 @@ * provides WP credentials + a base URL. * * Required env (any one set is enough): - * - GRAVITYVIEW_BASE_URL + GRAVITYVIEW_WP_USERNAME + - * GRAVITYVIEW_WP_APP_PASSWORD + * - GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + + * GRAVITYKIT_WP_APP_PASSWORD * - WORDPRESS_LOCAL_DEV_TEST_URL + * WORDPRESS_LOCAL_DEV_TEST_ADMIN_USER + * WORDPRESS_LOCAL_DEV_TEST_ADMIN_PASSWORD @@ -58,9 +58,9 @@ import dotenv from 'dotenv'; import fs from 'node:fs'; import path from 'node:path'; import GravityFormsClient from '../gravity-forms-client.js'; -import { GravityViewClient } from '../gravityview-client.js'; -import { ViewValidator } from '../view-operations/view-validator.js'; -import { loadAbilitiesAsTools } from '../view-operations/abilities-loader.js'; +import { GravityViewInspectorClient } from '../gravityview/inspector-client.js'; +import { ViewValidator } from '../gravityview/view-validator.js'; +import { loadAbilitiesAsTools } from '../abilities/loader.js'; import { TestRunner, TestAssert } from './helpers.js'; dotenv.config(); @@ -72,24 +72,24 @@ const suite = new TestRunner('GravityView REST stress tests (live)'); // Skip when no creds — keeps the unit-test job green on CI runners // without a backing WP install. const baseUrl = - process.env.GRAVITYVIEW_BASE_URL || + process.env.GRAVITYKIT_WP_URL || process.env.WORDPRESS_LOCAL_DEV_TEST_URL || process.env.GRAVITY_FORMS_BASE_URL || ''; const wpUser = - process.env.GRAVITYVIEW_WP_USERNAME || + process.env.GRAVITYKIT_WP_USERNAME || process.env.WORDPRESS_LOCAL_DEV_TEST_ADMIN_USER || process.env.WP_USERNAME || ''; const wpPass = - process.env.GRAVITYVIEW_WP_APP_PASSWORD || + process.env.GRAVITYKIT_WP_APP_PASSWORD || process.env.WORDPRESS_LOCAL_DEV_TEST_ADMIN_PASSWORD || process.env.WP_APP_PASSWORD || ''; const hasCreds = Boolean(baseUrl && wpUser && wpPass); if (!hasCreds) { - console.log('\n⚠️ Skipping GravityView stress tests — set GRAVITYVIEW_BASE_URL + GRAVITYVIEW_WP_USERNAME + GRAVITYVIEW_WP_APP_PASSWORD (or the WORDPRESS_LOCAL_DEV_TEST_* equivalents) to run.\n'); + console.log('\n⚠️ Skipping GravityView stress tests — set GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD (or the WORDPRESS_LOCAL_DEV_TEST_* equivalents) to run.\n'); suite.skip = true; } @@ -101,7 +101,7 @@ let fieldIds = {}; // { name, email, address, date, fileupload, textarea, ch let mintedViewIds = []; // tracked for end-of-suite cleanup // Abilities API tool handlers — auto-generated from -// `/wp-abilities/v1/abilities`. Replaces the legacy GravityViewClient +// `/wp-abilities/v1/abilities`. Replaces the legacy GravityViewInspectorClient // method calls now that the inspector lives entirely on the // Abilities API surface (`/wp-json/wp-abilities/v1/abilities/gk-gravityview/{name}/run`). // `h` is a short alias used throughout the test bodies — every old @@ -129,13 +129,13 @@ suite.beforeAll(async () => { if (suite.skip) return; const clientEnv = { - GRAVITYVIEW_BASE_URL: baseUrl, - GRAVITYVIEW_WP_USERNAME: wpUser, - GRAVITYVIEW_WP_APP_PASSWORD: wpPass, + GRAVITYKIT_WP_URL: baseUrl, + GRAVITYKIT_WP_USERNAME: wpUser, + GRAVITYKIT_WP_APP_PASSWORD: wpPass, GRAVITYVIEW_ALLOW_DELETE: 'true', MCP_ALLOW_SELF_SIGNED_CERTS: allowSelfSigned ? 'true' : 'false', }; - gvClient = new GravityViewClient(clientEnv); + gvClient = new GravityViewInspectorClient(clientEnv); validator = new ViewValidator(gvClient); // Load the abilities catalog → builds gv_* tool handlers that @@ -229,7 +229,7 @@ suite.afterAll(async () => { if (!cleanup) return; // Tear down minted views via gv_apply_view_config replace + empty // tree (clears placements), then the underlying WP posts via the - // wp/v2 endpoint. GravityViewClient doesn't expose a delete-view + // wp/v2 endpoint. GravityViewInspectorClient doesn't expose a delete-view // method (intentional — destructive), so hit the WP REST surface // directly with the same basic-auth headers. for (const viewId of mintedViewIds) { @@ -2634,7 +2634,7 @@ suite.test('Safety: abilities-loader does NOT add a client-side destructive gate // ability registry, so the env-var ratchet served no remaining // purpose. Status-level removal still flows through gv_set_view_status // and is gated server-side by the WP `delete_post` capability. - const { loadAbilitiesAsTools } = await import('../view-operations/abilities-loader.js'); + const { loadAbilitiesAsTools } = await import('../abilities/loader.js'); const { handlers } = await loadAbilitiesAsTools(gvClient); let msg = ''; try { diff --git a/src/tests/views.test.js b/src/tests/views.test.js index 568edaf..8284c8d 100644 --- a/src/tests/views.test.js +++ b/src/tests/views.test.js @@ -7,8 +7,8 @@ * mode replace/merge, area-key URL encoding). */ -import { GravityViewClient } from '../gravityview-client.js'; -import { ViewValidator } from '../view-operations/view-validator.js'; +import { GravityViewInspectorClient } from '../gravityview/inspector-client.js'; +import { ViewValidator } from '../gravityview/view-validator.js'; import { TestRunner, TestAssert, @@ -24,11 +24,11 @@ let mockHttpClient; let testEnv; suite.beforeEach(() => { - // GravityViewClient falls back to GRAVITY_FORMS_* creds, so the + // GravityViewInspectorClient falls back to GRAVITY_FORMS_* creds, so the // shared setupTestEnvironment values cover both surfaces. testEnv = setupTestEnvironment(); mockHttpClient = new MockHttpClient(); - client = new GravityViewClient(testEnv); + client = new GravityViewInspectorClient(testEnv); client.httpClient = mockHttpClient; // bypass real network }); @@ -37,21 +37,21 @@ suite.beforeEach(() => { // ==================================================================== suite.test('Constructor: throws without a base URL', () => { - TestAssert.throws(() => new GravityViewClient({}), 'GRAVITYVIEW_BASE_URL'); + TestAssert.throws(() => new GravityViewInspectorClient({}), 'GRAVITYKIT_WP_URL'); }); suite.test('Constructor: throws without credentials', () => { TestAssert.throws( - () => new GravityViewClient({ GRAVITYVIEW_BASE_URL: 'https://example.com' }), + () => new GravityViewInspectorClient({ GRAVITYKIT_WP_URL: 'https://example.com' }), 'WordPress credentials' ); }); suite.test('Constructor: builds Basic auth header from WP creds', () => { - const c = new GravityViewClient({ - GRAVITYVIEW_BASE_URL: 'https://example.com', - GRAVITYVIEW_WP_USERNAME: 'admin', - GRAVITYVIEW_WP_APP_PASSWORD: 'abc def ghi jkl', + const c = new GravityViewInspectorClient({ + GRAVITYKIT_WP_URL: 'https://example.com', + GRAVITYKIT_WP_USERNAME: 'admin', + GRAVITYKIT_WP_APP_PASSWORD: 'abc def ghi jkl', }); TestAssert.equal( c.basicAuth, @@ -60,7 +60,7 @@ suite.test('Constructor: builds Basic auth header from WP creds', () => { }); suite.test('Constructor: falls back to GRAVITY_FORMS_CONSUMER_KEY/SECRET', () => { - const c = new GravityViewClient({ + const c = new GravityViewInspectorClient({ GRAVITY_FORMS_BASE_URL: 'https://example.com', GRAVITY_FORMS_CONSUMER_KEY: 'fk', GRAVITY_FORMS_CONSUMER_SECRET: 'fs', diff --git a/src/wp-client.js b/src/wp-client.js new file mode 100644 index 0000000..12c0cfb --- /dev/null +++ b/src/wp-client.js @@ -0,0 +1,93 @@ +/** + * Authenticated WordPress transport for GravityKit MCP. + * + * Product-agnostic: this is the client the abilities loader rides to + * reach the Foundation catalog (`/wp-json/gravitykit/v1/...`), the WP + * core Abilities API (`/wp-json/wp-abilities/v1/...`), and any other + * WP-root REST surface. Product-specific clients (e.g. the GravityView + * Inspector test client) extend it and add their own namespace. + * + * Authentication: WordPress Application Password via HTTP Basic Auth. + * The same WP install usually hosts the GF REST surface too, so when + * GRAVITYKIT_WP_* credentials aren't set we fall back to + * GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET (which in + * practice are usually a WP user + app password as well — most + * local-dev setups reuse them rather than minting two credentials). + */ + +import axios from 'axios'; +import https from 'https'; + +export class WordPressClient { + constructor(config) { + this.config = config || {}; + + const baseUrl = this.resolveBaseUrl(); + if (!baseUrl) { + throw new Error('WordPress client requires GRAVITYKIT_WP_URL or GRAVITY_FORMS_BASE_URL.'); + } + if (!baseUrl.startsWith('https://') && !baseUrl.startsWith('http://')) { + throw new Error('WordPress base URL must start with http:// or https://'); + } + + this.baseUrl = baseUrl.replace(/\/$/, ''); + + // Auth resolution order: canonical GRAVITYKIT_WP_* (prod-style) → + // WORDPRESS_LOCAL_DEV_TEST_* (the local dev.test admin creds; same + // values reused by any other MonoKit tool that hits the local + // install) → generic WP_USERNAME → GF MCP consumer key fallback. + // The descriptive local-dev names exist so this single admin + // credential isn't duplicated across every per-product env block. + const username = this.config.GRAVITYKIT_WP_USERNAME + || this.config.WORDPRESS_LOCAL_DEV_TEST_ADMIN_USER + || this.config.WP_USERNAME + || this.config.GRAVITY_FORMS_CONSUMER_KEY; + const password = this.config.GRAVITYKIT_WP_APP_PASSWORD + || this.config.WORDPRESS_LOCAL_DEV_TEST_ADMIN_PASSWORD + || this.config.WP_APP_PASSWORD + || this.config.GRAVITY_FORMS_CONSUMER_SECRET; + if (!username || !password) { + throw new Error('WordPress client requires credentials. Set GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD, or WORDPRESS_LOCAL_DEV_TEST_ADMIN_USER + _ADMIN_PASSWORD, or reuse GRAVITY_FORMS_CONSUMER_KEY/SECRET.'); + } + this.basicAuth = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); + + this.allowSelfSigned = (this.config.GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS || this.config.MCP_ALLOW_SELF_SIGNED_CERTS) === 'true'; + this.timeoutMs = parseInt(this.config.GRAVITYKIT_TIMEOUT || this.config.GRAVITY_FORMS_TIMEOUT, 10) || 30000; + + // Rooted at the WP install. Subclasses may replace this with a + // namespaced instance via createHttpClient(); callers that need a + // different root per request (the abilities loader) pass an + // explicit `baseURL` in the request config, which wins either way. + this.httpClient = this.createHttpClient(this.baseUrl); + } + + resolveBaseUrl() { + return this.config.GRAVITYKIT_WP_URL + || this.config.WORDPRESS_LOCAL_DEV_TEST_URL + || this.config.GRAVITY_FORMS_BASE_URL + || ''; + } + + /** + * Build an axios instance carrying this client's auth, timeout, and + * TLS settings. Subclasses use it to mount namespaced clients. + * + * @param {string} baseURL Absolute base URL for the instance. + * @returns {import('axios').AxiosInstance} + */ + createHttpClient(baseURL) { + return axios.create({ + baseURL, + timeout: this.timeoutMs, + headers: { + 'User-Agent': 'GravityKit-MCP/2.1.1', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': this.basicAuth, + }, + httpsAgent: new https.Agent({ rejectUnauthorized: !this.allowSelfSigned }), + }); + } +} + +export default WordPressClient; From 6be7d733c5b134919aa33059876d9a981a253dfb Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 11 Jun 2026 16:18:18 -0400 Subject: [PATCH 09/36] =?UTF-8?q?feat:=20independent=20capability=20planes?= =?UTF-8?q?=20=E2=80=94=20GF=20always,=20GravityKit=20via=20Foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plane A (Gravity Forms): the 26 static gf_* tools over GF REST v2 — works on any Gravity Forms site, no Foundation/WP 6.9 required. Tool literals extracted to GF_TOOL_DEFINITIONS at module scope. Plane B (GravityKit abilities): dynamic tools from the Foundation catalog (all GK products) with WP-core fallback. Lights up only when Foundation is active on the connected site. Changes: - initializeClient() split into per-plane initializers. A GravityView site without GF REST keys now still gets abilities tools; a GF-only site is unaffected. Throws only when NEITHER plane has credentials. Per-plane 60s retry cooldown prevents re-validation storms. - Abilities load failures now back off for 60s instead of re-fetching on every tools/list (Foundation-less sites no longer pay two failed requests per list, forever). gv_reload_abilities bypasses cooldown. - RESERVED_TOOL_NAMES: the loader's collision guard is seeded with all built-in tool names, so a future catalog-served gk-gravity-forms ability can never shadow the released gf_* contract. - Server instructions now state plane availability (gf_* works anywhere; product tools require Foundation on the site). --- src/abilities/loader.js | 25 +- src/index.js | 899 +++++++++++++++-------------- src/tests/abilities-loader.test.js | 30 + 3 files changed, 526 insertions(+), 428 deletions(-) diff --git a/src/abilities/loader.js b/src/abilities/loader.js index 6e1de28..3d1313a 100644 --- a/src/abilities/loader.js +++ b/src/abilities/loader.js @@ -161,15 +161,20 @@ function arrayToProperties(arr) { * * @param {object} wpClient WordPressClient instance — uses its * authenticated httpClient. + * @param {object} [options] + * @param {Set} [options.reservedNames] Tool names owned by the + * built-in (static) tool set — e.g. the released gf_* contract. + * Catalog abilities resolving to a reserved name are skipped with a + * warning so the dynamic pipeline can never shadow a shipped tool. * @returns {Promise<{ definitions: object[], handlers: Record, count: number, source: 'foundation-catalog'|'wp-core' }>} */ -export async function loadAbilitiesAsTools(wpClient) { +export async function loadAbilitiesAsTools(wpClient, { reservedNames } = {}) { try { const items = await fetchFoundationCatalogItems(wpClient); const entries = catalogItemsToEntries(items); if (entries.length > 0) { - return buildTools(wpClient, entries, 'foundation-catalog'); + return buildTools(wpClient, entries, 'foundation-catalog', reservedNames); } logger.warn(`Foundation catalog at ${FOUNDATION_CATALOG_ROUTE} returned no usable abilities — falling back to WP core catalog`); @@ -178,7 +183,7 @@ export async function loadAbilitiesAsTools(wpClient) { } const entries = await fetchCoreEntries(wpClient); - return buildTools(wpClient, entries, 'wp-core'); + return buildTools(wpClient, entries, 'wp-core', reservedNames); } /** @@ -200,9 +205,8 @@ async function fetchFoundationCatalogItems(wpClient) { let totalPages = 1; do { - // wpClient.httpClient is namespaced to /gravityview/v1 — override - // baseURL per-request so the URL resolves at the WP root (same - // auth + TLS config, no second axios instance). + // Explicit baseURL per request keeps this correct even when a + // subclass mounts a namespaced httpClient (same auth + TLS). const response = await wpClient.httpClient.request({ method: 'GET', baseURL: wpClient.baseUrl, @@ -323,13 +327,20 @@ async function fetchCoreEntries(wpClient) { * @param {object} wpClient WordPressClient instance. * @param {Array} entries Normalized tool entries. * @param {string} source Which catalog produced the entries. + * @param {Set} [reservedNames] Names owned by the built-in tool set. * @returns {{ definitions: object[], handlers: Record, count: number, source: string }} */ -function buildTools(wpClient, entries, source) { +function buildTools(wpClient, entries, source, reservedNames) { const definitions = []; const handlers = {}; const claimedBy = new Map(); + if (reservedNames) { + for (const name of reservedNames) { + claimedBy.set(name, 'a built-in tool'); + } + } + for (const entry of entries) { const existing = claimedBy.get(entry.toolName); if (existing) { diff --git a/src/index.js b/src/index.js index 9fbad5b..b8ce4a1 100644 --- a/src/index.js +++ b/src/index.js @@ -44,7 +44,7 @@ const server = new Server( capabilities: { tools: { listChanged: true } }, - instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields) and gv_* for GravityView Views.\n\nGravityView authoring flow: 1) gv_view_create to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_grid_row_add (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_view_config_apply for bulk one-shot writes, or gv_view_field_add / gv_view_field_patch / gv_view_field_move for surgical edits. 4) For Search Bar internal layout, use gv_search_field_add / gv_search_field_patch / gv_search_field_remove — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_layouts_list (Layout Builder, DIY, Table, List, DataTables, Map — with is_grid_aware flag), gv_widgets_list, gv_grid_row_types_list, gv_widget_zones_list (header/footer), gv_search_zones_list (search-general/search-advanced), gv_available_fields_get. Schema: gv_field_type_schema_get works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_view_field_move accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' + instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields — works on any Gravity Forms site) and product tools (gv_* for GravityView Views, plus other GravityKit products) generated from the site\'s GravityKit Foundation abilities catalog — these appear only when Foundation is active on the connected site.\n\nGravityView authoring flow: 1) gv_view_create to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_grid_row_add (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_view_config_apply for bulk one-shot writes, or gv_view_field_add / gv_view_field_patch / gv_view_field_move for surgical edits. 4) For Search Bar internal layout, use gv_search_field_add / gv_search_field_patch / gv_search_field_remove — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_layouts_list (Layout Builder, DIY, Table, List, DataTables, Map — with is_grid_aware flag), gv_widgets_list, gv_grid_row_types_list, gv_widget_zones_list (header/footer), gv_search_zones_list (search-general/search-advanced), gv_available_fields_get. Schema: gv_field_type_schema_get works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_view_field_move accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' } ); @@ -61,24 +61,61 @@ let wpClient = null; let abilityToolDefinitions = null; let abilityToolHandlers = null; // In-flight catalog fetch. Single-flight: concurrent callers share the -// same promise. On rejection it's cleared so the NEXT call retries — +// same promise. On rejection it's cleared so a later call retries — // covers transient cert / network / WP-not-yet-booted failures without -// requiring an MCP process restart. +// requiring an MCP process restart. Retries are bounded by a cooldown +// so Foundation-less sites (gf_* only) don't pay two failed requests +// on every tools/list forever; gv_reload_abilities bypasses it. let abilitiesLoadPromise = null; +let abilitiesFailedAt = 0; +const ABILITIES_RETRY_COOLDOWN_MS = 60_000; /** - * Initialize Gravity Forms client + * Initialize the two independent capability planes. + * + * Plane A — Gravity Forms: static gf_* tools over GF REST v2. Works on + * any Gravity Forms site; requires only GRAVITY_FORMS_* credentials. + * + * Plane B — GravityKit abilities: dynamic tools from the Foundation + * catalog (all GravityKit products) with WP-core fallback. Requires a + * WordPress app password (or the GF credential fallback) and lights up + * only when Foundation is active on the site. + * + * Each plane initializes independently and degrades independently — + * a GF-only site gets the full gf_* surface with no abilities, and a + * GravityKit site without GF REST keys still gets abilities. Failed + * planes retry on later calls, bounded by a cooldown. + * + * @throws when NEITHER plane has usable credentials. */ +const INIT_RETRY_COOLDOWN_MS = 60_000; +let gfPlaneFailedAt = 0; +let wpPlaneFailedAt = 0; + async function initializeClient() { + const gfOk = await initializeGravityFormsPlane(); + const wpOk = initializeWordPressPlane(); + + if (!gfOk && !wpOk) { + throw new Error('Neither Gravity Forms nor WordPress credentials are usable. Set GRAVITY_FORMS_* and/or GRAVITYKIT_WP_* in .env.'); + } + + return true; +} + +async function initializeGravityFormsPlane() { + if (gravityFormsClient) return true; + if (Date.now() - gfPlaneFailedAt < INIT_RETRY_COOLDOWN_MS) return false; + try { - gravityFormsClient = new GravityFormsClient(process.env); - const validation = await gravityFormsClient.initialize(); + const client = new GravityFormsClient(process.env); + const validation = await client.initialize(); if (!validation.available) { - throw new Error(`Failed to initialize Gravity Forms client: ${validation.error}`); + throw new Error(validation.error); } - // Initialize field operations infrastructure + gravityFormsClient = client; fieldValidator = new FieldAwareValidator(); fieldOperations = createFieldOperations( gravityFormsClient, @@ -86,39 +123,38 @@ async function initializeClient() { fieldValidator ); - logger.info('✅ GravityKit MCP initialized successfully'); - logger.info('✅ Field operations infrastructure initialized'); + logger.info('✅ Gravity Forms client initialized — gf_* tools available'); + return true; + } catch (gfError) { + gfPlaneFailedAt = Date.now(); + logger.warn(`⚠️ Gravity Forms client unavailable: ${gfError.message} — gf_* tools disabled (will retry)`); + return false; + } +} + +function initializeWordPressPlane() { + if (wpClient) return true; + if (Date.now() - wpPlaneFailedAt < INIT_RETRY_COOLDOWN_MS) return false; + try { // WordPress client — the authenticated transport to the WP root // (Foundation catalog + WP core Abilities API). Credentials and - // base URL are resolved independently of the GF REST endpoint. The - // constructor allows credential fallback to GRAVITY_FORMS_* - // env vars so single-WP-install setups don't need to mint - // two separate app passwords. - try { - wpClient = new WordPressClient(process.env); - logger.info('✅ WordPress client initialized — loading GravityKit abilities'); - - // Fire-and-forget: kick off the abilities catalog fetch in the - // background so MCP startup is fast. ListTools awaits up to 2s - // for it (long enough for a warm cold-start on dev.test, short - // enough to never feel like a hang); gv_* tool calls await with - // no timeout. On success we emit `tools/list_changed` so the - // client refetches with the abilities-derived schemas. - ensureAbilitiesLoaded(); - } catch (gvError) { - // Don't fail the whole MCP if GravityView credentials are - // missing — gf_* tools still work standalone. - logger.warn(`⚠️ WordPress client unavailable: ${gvError.message}`); - wpClient = null; - abilityToolDefinitions = null; - abilityToolHandlers = null; - } - + // base URL are resolved independently of the GF REST endpoint, + // with fallback to GRAVITY_FORMS_* so single-WP-install setups + // don't need to mint two separate credentials. + wpClient = new WordPressClient(process.env); + logger.info('✅ WordPress client initialized — loading GravityKit abilities'); + + // Fire-and-forget: kick off the abilities catalog fetch in the + // background so MCP startup is fast. ListTools awaits up to 2s + // for it; per-call self-heal and gv_reload_abilities retry later. + ensureAbilitiesLoaded(); return true; - } catch (error) { - logger.error(`❌ Failed to initialize: ${error.message}`); - throw error; + } catch (wpError) { + wpPlaneFailedAt = Date.now(); + wpClient = null; + logger.warn(`⚠️ WordPress client unavailable: ${wpError.message} — abilities tools disabled (will retry)`); + return false; } } @@ -146,10 +182,12 @@ async function ensureAbilitiesLoaded({ force = false, timeoutMs } = {}) { abilityToolDefinitions = null; abilityToolHandlers = null; abilitiesLoadPromise = null; + abilitiesFailedAt = 0; } if (abilityToolDefinitions) return; + if (!abilitiesLoadPromise && Date.now() - abilitiesFailedAt < ABILITIES_RETRY_COOLDOWN_MS) return; if (!abilitiesLoadPromise) { - abilitiesLoadPromise = loadAbilitiesAsTools(wpClient) + abilitiesLoadPromise = loadAbilitiesAsTools(wpClient, { reservedNames: RESERVED_TOOL_NAMES }) .then(({ definitions, handlers, count, source }) => { abilityToolDefinitions = definitions; abilityToolHandlers = handlers; @@ -163,8 +201,9 @@ async function ensureAbilitiesLoaded({ force = false, timeoutMs } = {}) { }); }) .catch((err) => { - logger.warn(`⚠️ Abilities API catalog unavailable: ${err.message} — gv_* tools unavailable until a catalog is reachable (will retry on next call)`); - abilitiesLoadPromise = null; // clear so next call retries + logger.warn(`⚠️ Abilities API catalog unavailable: ${err.message} — abilities tools unavailable until a catalog is reachable (next retry after cooldown, or gv_reload_abilities)`); + abilitiesFailedAt = Date.now(); + abilitiesLoadPromise = null; // clear so a later call retries throw err; }); } @@ -266,423 +305,439 @@ function wrapViewHandler(handler, params = {}) { // FORMS MANAGEMENT TOOLS (6) // ================================= -server.setRequestHandler(ListToolsRequestSchema, async () => { - // ListTools is the FIRST request Claude fires after the MCP - // handshake, so we lazily initialise here too — otherwise the - // tool list goes out before initializeClient() has had a chance - // to construct the GravityView client + start the abilities load. - if (!gravityFormsClient) { - try { await initializeClient(); } catch (_) { /* gf_* still serves */ } - } - // Best-effort wait for the abilities catalog. 2s covers a warm - // cold-start on dev.test (~800ms) plus headroom; if WP is - // genuinely unreachable the list ships without gv_* tools and the - // next gv_* call (or gv_reload_abilities) retries. - await ensureAbilitiesLoaded({ timeoutMs: 2000 }); - - return { - tools: [ - // Forms Management (6 tools) - { - name: 'gf_list_forms', - description: 'List all forms with optional search and pagination.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - include: { - type: 'array', - items: { type: 'number' }, - description: 'Form IDs to include' - }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - } - } - }, - { - name: 'gf_get_form', - description: 'Get a form by ID with full field configuration.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Form ID' }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - }, - required: ['id'] - } +// Static Gravity Forms tool definitions (Plane A — works on any GF site, +// no Foundation required). These names are the released contract; the +// abilities loader treats them as reserved so a future catalog-served +// gk-gravity-forms ability can never shadow them. +const GF_TOOL_DEFINITIONS = [ + // Forms Management (6 tools) + { + name: 'gf_list_forms', + description: 'List all forms with optional search and pagination.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + include: { + type: 'array', + items: { type: 'number' }, + description: 'Form IDs to include' + }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } + } + } + }, + { + name: 'gf_get_form', + description: 'Get a form by ID with full field configuration.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Form ID' }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } }, - { - name: 'gf_create_form', - description: 'Create a new form', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Form title' }, - description: { type: 'string', description: 'Form description' }, - fields: { - type: 'array', - description: 'Array of field objects', - items: { type: 'object' } - }, - button: { type: 'object', description: 'Submit button settings' }, - confirmations: { type: 'object', description: 'Confirmation settings' }, - notifications: { type: 'object', description: 'Notification settings' }, - is_active: { type: 'boolean', description: 'Form active state' } - }, - required: ['title'] - } + required: ['id'] + } + }, + { + name: 'gf_create_form', + description: 'Create a new form', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Form title' }, + description: { type: 'string', description: 'Form description' }, + fields: { + type: 'array', + description: 'Array of field objects', + items: { type: 'object' } + }, + button: { type: 'object', description: 'Submit button settings' }, + confirmations: { type: 'object', description: 'Confirmation settings' }, + notifications: { type: 'object', description: 'Notification settings' }, + is_active: { type: 'boolean', description: 'Form active state' } }, - { - name: 'gf_update_form', - description: 'Update a form', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Form ID' }, - title: { type: 'string', description: 'Form title' }, - description: { type: 'string', description: 'Form description' }, - fields: { - type: 'array', - description: 'Array of field objects', - items: { type: 'object' } - }, - button: { type: 'object', description: 'Submit button settings' }, - confirmations: { type: 'object', description: 'Confirmation settings' }, - notifications: { type: 'object', description: 'Notification settings' }, - is_active: { type: 'boolean', description: 'Form active state' } - }, - required: ['id'] - } + required: ['title'] + } + }, + { + name: 'gf_update_form', + description: 'Update a form', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Form ID' }, + title: { type: 'string', description: 'Form title' }, + description: { type: 'string', description: 'Form description' }, + fields: { + type: 'array', + description: 'Array of field objects', + items: { type: 'object' } + }, + button: { type: 'object', description: 'Submit button settings' }, + confirmations: { type: 'object', description: 'Confirmation settings' }, + notifications: { type: 'object', description: 'Notification settings' }, + is_active: { type: 'boolean', description: 'Form active state' } }, - { - name: 'gf_delete_form', - description: 'Delete a form (requires ALLOW_DELETE=true)', - annotations: { destructiveHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Form ID' }, - force: { type: 'boolean', description: 'Permanent delete (vs trash)' } - }, - required: ['id'] - } + required: ['id'] + } + }, + { + name: 'gf_delete_form', + description: 'Delete a form (requires ALLOW_DELETE=true)', + annotations: { destructiveHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Form ID' }, + force: { type: 'boolean', description: 'Permanent delete (vs trash)' } }, - { - name: 'gf_validate_form', - description: 'Validate form data', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' } - }, - additionalProperties: true, - required: ['form_id'] - } + required: ['id'] + } + }, + { + name: 'gf_validate_form', + description: 'Validate form data', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' } }, + additionalProperties: true, + required: ['form_id'] + } + }, - // Entries Management (5 tools) - { - name: 'gf_list_entries', - description: 'List/search entries with filtering, sorting, and pagination.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { + // Entries Management (5 tools) + { + name: 'gf_list_entries', + description: 'List/search entries with filtering, sorting, and pagination.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_ids: { + type: 'array', + items: { type: 'number' }, + description: 'Filter by form IDs' + }, + include: { + type: 'array', + items: { type: 'number' }, + description: 'Entry IDs to include' + }, + exclude: { + type: 'array', + items: { type: 'number' }, + description: 'Entry IDs to exclude' + }, + status: { + type: 'string', + enum: ['active', 'spam', 'trash'], + description: 'Entry status' + }, + search: { type: 'object', properties: { - form_ids: { - type: 'array', - items: { type: 'number' }, - description: 'Filter by form IDs' - }, - include: { - type: 'array', - items: { type: 'number' }, - description: 'Entry IDs to include' - }, - exclude: { + field_filters: { type: 'array', - items: { type: 'number' }, - description: 'Entry IDs to exclude' - }, - status: { - type: 'string', - enum: ['active', 'spam', 'trash'], - description: 'Entry status' - }, - search: { - type: 'object', - properties: { - field_filters: { - type: 'array', - items: { - type: 'object', - properties: { - key: { type: 'string' }, - value: { type: 'string' }, - operator: { - type: 'string', - enum: ['=', 'IS', 'CONTAINS', 'IS NOT', 'ISNOT', '<>', 'LIKE', 'NOT IN', 'NOTIN', 'IN', '>', '<', '>=', '<='] - } - } + items: { + type: 'object', + properties: { + key: { type: 'string' }, + value: { type: 'string' }, + operator: { + type: 'string', + enum: ['=', 'IS', 'CONTAINS', 'IS NOT', 'ISNOT', '<>', 'LIKE', 'NOT IN', 'NOTIN', 'IN', '>', '<', '>=', '<='] } - }, - mode: { - type: 'string', - enum: ['any', 'all'], - description: 'Search mode' } } }, - sorting: { - type: 'object', - properties: { - key: { type: 'string' }, - direction: { - type: 'string', - enum: ['asc', 'desc', 'ASC', 'DESC'] - } - } - }, - paging: { - type: 'object', - properties: { - page_size: { type: 'number' }, - current_page: { type: 'number' } - } - }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - } - } - }, - { - name: 'gf_get_entry', - description: 'Get an entry by ID with field labels.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Entry ID' }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - }, - required: ['id'] - } - }, - { - name: 'gf_create_entry', - description: 'Create an entry', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' }, - created_by: { type: 'number', description: 'Creator user ID' }, - status: { + mode: { type: 'string', - enum: ['active', 'spam', 'trash'], - description: 'Entry status' - }, - date_created: { type: 'string', description: 'ISO date' } - }, - additionalProperties: true, - required: ['form_id'] - } - }, - { - name: 'gf_update_entry', - description: 'Update an entry', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { + enum: ['any', 'all'], + description: 'Search mode' + } + } + }, + sorting: { type: 'object', properties: { - id: { type: 'number', description: 'Entry ID' }, - status: { + key: { type: 'string' }, + direction: { type: 'string', - enum: ['active', 'spam', 'trash'], - description: 'Entry status' + enum: ['asc', 'desc', 'ASC', 'DESC'] } - }, - additionalProperties: true, - required: ['id'] - } - }, - { - name: 'gf_delete_entry', - description: 'Delete an entry (requires ALLOW_DELETE=true)', - annotations: { destructiveHint: true, openWorldHint: true }, - inputSchema: { + } + }, + paging: { type: 'object', properties: { - id: { type: 'number', description: 'Entry ID' }, - force: { type: 'boolean', description: 'Permanent delete (vs trash)' } - }, - required: ['id'] - } + page_size: { type: 'number' }, + current_page: { type: 'number' } + } + }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } + } + } + }, + { + name: 'gf_get_entry', + description: 'Get an entry by ID with field labels.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Entry ID' }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } }, - - // Form Submissions (2 tools) - { - name: 'gf_submit_form_data', - description: 'Submit form data (triggers notifications, confirmations, payment)', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' }, - field_values: { type: 'object', description: 'Field values' } - }, - additionalProperties: true, - required: ['form_id'] - } + required: ['id'] + } + }, + { + name: 'gf_create_entry', + description: 'Create an entry', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' }, + created_by: { type: 'number', description: 'Creator user ID' }, + status: { + type: 'string', + enum: ['active', 'spam', 'trash'], + description: 'Entry status' + }, + date_created: { type: 'string', description: 'ISO date' } }, - { - name: 'gf_validate_submission', - description: 'Validate submission without processing', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' } - }, - additionalProperties: true, - required: ['form_id'] + additionalProperties: true, + required: ['form_id'] + } + }, + { + name: 'gf_update_entry', + description: 'Update an entry', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Entry ID' }, + status: { + type: 'string', + enum: ['active', 'spam', 'trash'], + description: 'Entry status' } }, + additionalProperties: true, + required: ['id'] + } + }, + { + name: 'gf_delete_entry', + description: 'Delete an entry (requires ALLOW_DELETE=true)', + annotations: { destructiveHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Entry ID' }, + force: { type: 'boolean', description: 'Permanent delete (vs trash)' } + }, + required: ['id'] + } + }, - // Notifications (1 tool) - { - name: 'gf_send_notifications', - description: 'Send notifications for entry', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - entry_id: { type: 'number', description: 'Entry ID' }, - notification_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Notification IDs to send' - } - }, - required: ['entry_id'] - } + // Form Submissions (2 tools) + { + name: 'gf_submit_form_data', + description: 'Submit form data (triggers notifications, confirmations, payment)', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' }, + field_values: { type: 'object', description: 'Field values' } + }, + additionalProperties: true, + required: ['form_id'] + } + }, + { + name: 'gf_validate_submission', + description: 'Validate submission without processing', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' } }, + additionalProperties: true, + required: ['form_id'] + } + }, - // Add-on Feeds (7 tools) - { - name: 'gf_list_feeds', - description: 'List feeds. Filter by form_id and/or addon slug.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - addon: { type: 'string', description: 'Addon slug' }, - form_id: { type: 'number', description: 'Form ID' }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - } + // Notifications (1 tool) + { + name: 'gf_send_notifications', + description: 'Send notifications for entry', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + entry_id: { type: 'number', description: 'Entry ID' }, + notification_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Notification IDs to send' } }, - { - name: 'gf_get_feed', - description: 'Get a feed by ID.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Feed ID' }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - }, - required: ['id'] - } + required: ['entry_id'] + } + }, + + // Add-on Feeds (7 tools) + { + name: 'gf_list_feeds', + description: 'List feeds. Filter by form_id and/or addon slug.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + addon: { type: 'string', description: 'Addon slug' }, + form_id: { type: 'number', description: 'Form ID' }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } + } + } + }, + { + name: 'gf_get_feed', + description: 'Get a feed by ID.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Feed ID' }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } }, - // gf_list_form_feeds removed — gf_list_feeds with form_id does the same thing - // and also supports addon filtering. Kept listFormFeeds() client method for - // backwards compatibility but no longer exposed as a tool. - { - name: 'gf_create_feed', - description: 'Create a feed', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - addon_slug: { type: 'string', description: 'Add-on slug' }, - form_id: { type: 'number', description: 'Form ID' }, - is_active: { type: 'boolean', description: 'Feed active state' }, - meta: { type: 'object', description: 'Feed config' } - }, - required: ['addon_slug', 'form_id', 'meta'] - } + required: ['id'] + } + }, + // gf_list_form_feeds removed — gf_list_feeds with form_id does the same thing + // and also supports addon filtering. Kept listFormFeeds() client method for + // backwards compatibility but no longer exposed as a tool. + { + name: 'gf_create_feed', + description: 'Create a feed', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + addon_slug: { type: 'string', description: 'Add-on slug' }, + form_id: { type: 'number', description: 'Form ID' }, + is_active: { type: 'boolean', description: 'Feed active state' }, + meta: { type: 'object', description: 'Feed config' } }, - { - name: 'gf_update_feed', - description: 'Update a feed (full replace)', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Feed ID' }, - is_active: { type: 'boolean', description: 'Feed active state' }, - meta: { type: 'object', description: 'Feed config' } - }, - required: ['id'] - } + required: ['addon_slug', 'form_id', 'meta'] + } + }, + { + name: 'gf_update_feed', + description: 'Update a feed (full replace)', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Feed ID' }, + is_active: { type: 'boolean', description: 'Feed active state' }, + meta: { type: 'object', description: 'Feed config' } }, - { - name: 'gf_patch_feed', - description: 'Patch a feed (partial update)', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Feed ID' }, - is_active: { type: 'boolean', description: 'Feed active state' }, - meta: { type: 'object', description: 'Feed config' } - }, - required: ['id'] - } + required: ['id'] + } + }, + { + name: 'gf_patch_feed', + description: 'Patch a feed (partial update)', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Feed ID' }, + is_active: { type: 'boolean', description: 'Feed active state' }, + meta: { type: 'object', description: 'Feed config' } }, - { - name: 'gf_delete_feed', - description: 'Delete a feed (requires ALLOW_DELETE=true)', - annotations: { destructiveHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Feed ID' } - }, - required: ['id'] - } + required: ['id'] + } + }, + { + name: 'gf_delete_feed', + description: 'Delete a feed (requires ALLOW_DELETE=true)', + annotations: { destructiveHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Feed ID' } }, + required: ['id'] + } + }, - // Field Filters (1 tool) - { - name: 'gf_get_field_filters', - description: 'Get field filters for form', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' } - }, - required: ['form_id'] - } + // Field Filters (1 tool) + { + name: 'gf_get_field_filters', + description: 'Get field filters for form', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' } }, + required: ['form_id'] + } + }, - // Results (1 tool) - { - name: 'gf_get_results', - description: 'Get quiz/poll/survey results', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' } - }, - required: ['form_id'] - } + // Results (1 tool) + { + name: 'gf_get_results', + description: 'Get quiz/poll/survey results', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' } }, + required: ['form_id'] + } + }, +]; + +// Tool names the dynamic abilities pipeline must never claim. +const RESERVED_TOOL_NAMES = new Set([ + ...GF_TOOL_DEFINITIONS.map((tool) => tool.name), + ...fieldOperationTools.map((tool) => tool.name), + 'gv_reload_abilities', +]); + +server.setRequestHandler(ListToolsRequestSchema, async () => { + // ListTools is the FIRST request Claude fires after the MCP + // handshake, so we lazily initialise here too — otherwise the + // tool list goes out before initializeClient() has had a chance + // to construct the GravityView client + start the abilities load. + if (!gravityFormsClient || !wpClient) { + try { await initializeClient(); } catch (_) { /* serve whatever planes are up */ } + } + // Best-effort wait for the abilities catalog. 2s covers a warm + // cold-start on dev.test (~800ms) plus headroom; if WP is + // genuinely unreachable the list ships without gv_* tools and the + // next gv_* call (or gv_reload_abilities) retries. + await ensureAbilitiesLoaded({ timeoutMs: 2000 }); + + return { + tools: [ + // Gravity Forms (Plane A) — static, always-on when GF creds work + ...GF_TOOL_DEFINITIONS, // Field Operations (4 tools) - Intelligent field management ...fieldOperationTools, @@ -719,8 +774,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: params } = request.params; - // Ensure client is initialized - if (!gravityFormsClient) { + // Ensure capability planes are initialized. Per-plane failures + // surface as per-tool error responses below; throw only when + // neither plane has usable credentials. + if (!gravityFormsClient || !wpClient) { await initializeClient(); } diff --git a/src/tests/abilities-loader.test.js b/src/tests/abilities-loader.test.js index 3509db9..7d90bd1 100644 --- a/src/tests/abilities-loader.test.js +++ b/src/tests/abilities-loader.test.js @@ -381,6 +381,36 @@ suite.test('catalog path: tool-name collision — first wins, later skipped, nev TestAssert.equal(run.url, '/wp-json/wp-abilities/v1/abilities/gk-gravityview/views-list/run', 'handler must stay bound to the first claimant'); }); +suite.test('reserved names: catalog tools can never shadow the built-in gf_* contract', async () => { + const colliding = [ + { + // Hypothetical future gk-gravity-forms ability whose server name + // collides with a released built-in tool — must be skipped. + name: 'gk-gravity-forms/forms-list-legacy', + description: 'Catalog claimant for a built-in name', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gf_list_forms', + }, + { + name: 'gk-gravityview/views-list', + description: 'Safe name', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gv_views_list', + }, + ]; + const stub = buildCatalogStubGvClient([colliding]); + const { definitions, handlers, count } = await loadAbilitiesAsTools(stub, { + reservedNames: new Set(['gf_list_forms', 'gv_reload_abilities']), + }); + TestAssert.equal(count, 1, 'reserved-name claimant must be skipped'); + TestAssert.deepEqual(definitions.map((d) => d.name), ['gv_views_list']); + TestAssert.equal(handlers.gf_list_forms, undefined, 'no handler may bind to a reserved name'); +}); + suite.test('empty catalog → falls back to WP core path', async () => { const stub = buildCatalogStubGvClient([[]], { coreCatalog: syntheticCatalog() }); const { source, count } = await loadAbilitiesAsTools(stub); From f614ca7c74dd7f0c501da71f6d1921b20e07173b Mon Sep 17 00:00:00 2001 From: Vlad <2430296+mrcasual@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:01:04 -0400 Subject: [PATCH 10/36] Fix tests to work with the latest Foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four interlocking changes that get the live stress suite green: 1. methodForAbility (loader.js): require BOTH `destructive` AND `idempotent` for DELETE. Foundation's run controller (in WordPress core's wp-rest-abilities-v1-run-controller) only accepts DELETE when both flags are set, matching WP-REST conventions. view-delete is destructive but soft-trashes by default (force=false), so it is not idempotent — sending DELETE got a 405. Now POSTs. 2. abilities-loader.test.js: extend the methodForAbility unit test to cover the new logic, with an explicit regression case for `destructive + !idempotent → POST`. 3. views-stress.test.js: re-apply the verb-first → noun-first rename across 243 call sites (gv_apply_view_config → gv_view_config_apply etc.). Foundation's `/wp-json/gravitykit/v1/abilities` endpoint emits the canonical `mcp_tool_name` in noun-first shape, and the loader (post-7474ab0) treats that as authoritative. 4. views-stress.test.js: correct three stale assertions that no longer match Foundation's current contract: - "NO permanent-delete ability exists" → reframed as "default invocation soft-trashes" (mode=trash, deleted=true, force=false). view-delete IS shipped, with a safe-by-default soft-delete path. - dry-run response no longer flagged `would_apply` — Foundation FIX-22/66 (commit 05053d3d on the Foundation feature branch) intentionally dropped it as redundant with `dry_run`. - discovery-bridge test now looks up `gk-gravityview/layouts-list` (was `list-layouts`) and expects `grid-row-types-list` / `view-areas-get` in the has_grid description (was the old `list-grid-row-types` / `list-view-areas`). --- src/abilities/loader.js | 7 +- src/tests/abilities-loader.test.js | 8 +- src/tests/views-stress.test.js | 522 +++++++++++++++-------------- 3 files changed, 279 insertions(+), 258 deletions(-) diff --git a/src/abilities/loader.js b/src/abilities/loader.js index 3d1313a..e8f5217 100644 --- a/src/abilities/loader.js +++ b/src/abilities/loader.js @@ -55,7 +55,12 @@ const GK_NAME_PATTERN = /^gk-[a-z0-9-]+\//; */ export function methodForAbility(annotations = {}) { if (annotations.readonly) return 'GET'; - if (annotations.destructive) return 'DELETE'; + // Foundation's run controller only accepts DELETE for abilities that + // are BOTH destructive AND idempotent — matching WP-REST conventions + // for HTTP DELETE. Destructive-but-not-idempotent operations (e.g. + // view-delete with `force` defaulting to soft trash) must go through + // POST so their non-idempotent semantics are explicit on the wire. + if (annotations.destructive && annotations.idempotent) return 'DELETE'; return 'POST'; } diff --git a/src/tests/abilities-loader.test.js b/src/tests/abilities-loader.test.js index 7d90bd1..8113f96 100644 --- a/src/tests/abilities-loader.test.js +++ b/src/tests/abilities-loader.test.js @@ -457,9 +457,13 @@ suite.test('loadAbilitiesAsTools: healthy schema passes through untouched', asyn // regressions here would silently mis-route every gv_* call. // --------------------------------------------------------------------------- -suite.test('methodForAbility: readonly → GET, destructive → DELETE, else POST', () => { +suite.test('methodForAbility: readonly → GET, destructive+idempotent → DELETE, else POST', () => { TestAssert.equal(methodForAbility({ readonly: true }), 'GET'); - TestAssert.equal(methodForAbility({ destructive: true }), 'DELETE'); + TestAssert.equal(methodForAbility({ destructive: true, idempotent: true }), 'DELETE'); + // Destructive but NOT idempotent (e.g. view-delete with default soft trash) + // must POST — Foundation's run controller rejects DELETE on these with 405. + TestAssert.equal(methodForAbility({ destructive: true, idempotent: false }), 'POST'); + TestAssert.equal(methodForAbility({ destructive: true }), 'POST'); TestAssert.equal(methodForAbility({}), 'POST'); TestAssert.equal(methodForAbility(), 'POST'); }); diff --git a/src/tests/views-stress.test.js b/src/tests/views-stress.test.js index b58d19c..0195207 100644 --- a/src/tests/views-stress.test.js +++ b/src/tests/views-stress.test.js @@ -105,7 +105,7 @@ let mintedViewIds = []; // tracked for end-of-suite cleanup // method calls now that the inspector lives entirely on the // Abilities API surface (`/wp-json/wp-abilities/v1/abilities/gk-gravityview/{name}/run`). // `h` is a short alias used throughout the test bodies — every old -// `h.gv_apply_view_config({...})` is now `h.gv_apply_view_config({...})`. +// `h.gv_view_config_apply({...})` is now `h.gv_view_config_apply({...})`. let h = null; const cleanup = process.env.GRAVITYVIEW_TEST_CLEANUP !== 'false'; const allowSelfSigned = process.env.MCP_ALLOW_SELF_SIGNED_CERTS === 'true'; @@ -227,7 +227,7 @@ suite.afterAll(async () => { } if (!cleanup) return; - // Tear down minted views via gv_apply_view_config replace + empty + // Tear down minted views via gv_view_config_apply replace + empty // tree (clears placements), then the underlying WP posts via the // wp/v2 endpoint. GravityViewInspectorClient doesn't expose a delete-view // method (intentional — destructive), so hit the WP REST surface @@ -252,7 +252,7 @@ suite.afterAll(async () => { /** Mint a Layout Builder view + register it for cleanup. */ async function mintView(suffix) { - const view = await h.gv_create_view({ + const view = await h.gv_view_create({ title: `[stress] ${suffix} ${Date.now()}`, form_id: Number(formId), template_id: 'gravityview-layout-builder', @@ -265,14 +265,14 @@ async function mintView(suffix) { /** Apply one slot's settings + return the stored shape via GET /config. */ async function roundTripSlot(viewId, area, slot, settings) { - const apply = await h.gv_apply_view_config({ + const apply = await h.gv_view_config_apply({ id: viewId, fields: { [area]: [{ ...settings, slot }] }, mode: 'merge', }); TestAssert.isNotNull(apply.applied, 'apply response carries applied envelope'); - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); const stored = config?.fields?.[area]?.[slot]; TestAssert.isNotNull(stored, `slot ${slot} round-trips into ${area}`); return stored; @@ -280,11 +280,11 @@ async function roundTripSlot(viewId, area, slot, settings) { /** Stage helpers — now route through the abilities pipeline. */ async function createPreviewStage(viewId, payload) { - return h.gv_create_preview_stage({ id: viewId, ...payload }); + return h.gv_preview_stage_create({ id: viewId, ...payload }); } async function deletePreviewStage(viewId, stageKey) { - return h.gv_discard_preview_stage({ id: viewId, stage_key: stageKey }); + return h.gv_preview_stage_delete({ id: viewId, stage_key: stageKey }); } /** Recursive tree walker — does any node carry this key? */ @@ -307,7 +307,7 @@ const schemaItem = (schema, slug) => schema.find((it) => it?.slug === slug) || n suite.test('Shape: /layouts uses has_grid (not is_grid_aware), skips preset_* + *_placeholder', async () => { if (suite.skip) return; - const { layouts } = await h.gv_list_layouts({}); + const { layouts } = await h.gv_layouts_list({}); TestAssert.isTrue(Array.isArray(layouts) && layouts.length > 0, 'layouts array non-empty'); for (const layout of layouts) { @@ -329,13 +329,13 @@ suite.test('Shape: /layouts uses has_grid (not is_grid_aware), skips preset_* + suite.test('Shape: schema response omits the static groups map', async () => { if (suite.skip) return; - const resp = await h.gv_get_field_type_schema({ field_type: 'text' }); + const resp = await h.gv_field_type_schema_get({ field_type: 'text' }); TestAssert.isTrue(!('groups' in resp), 'No top-level `groups` map'); }); suite.test('Shape: schema items drop UI-only keys (priority/class/tooltip/article/codemirror/mount_target/extension)', async () => { if (suite.skip) return; - const resp = await h.gv_get_field_type_schema({ field_type: 'edit_link' }); + const resp = await h.gv_field_type_schema_get({ field_type: 'edit_link' }); const banned = ['priority', 'class', 'tooltip', 'article', 'codemirror', 'mount_target', 'extension']; for (const key of banned) { TestAssert.isTrue( @@ -347,7 +347,7 @@ suite.test('Shape: schema items drop UI-only keys (priority/class/tooltip/articl suite.test('Shape: schema items drop raw requires/requires_not DSL + the parsed-only intermediates', async () => { if (suite.skip) return; - const resp = await h.gv_get_field_type_schema({ field_type: 'text' }); + const resp = await h.gv_field_type_schema_get({ field_type: 'text' }); for (const item of resp.schema || []) { TestAssert.isTrue(!('requires_not' in item), 'raw requires_not DSL stripped'); TestAssert.isTrue(!('requires_parsed' in item), 'requires_parsed unified into requires.show'); @@ -358,7 +358,7 @@ suite.test('Shape: schema items drop raw requires/requires_not DSL + the parsed- suite.test('Shape: requires envelope uses show/hide sub-keys; single-condition collapses to leaf', async () => { if (suite.skip) return; - const resp = await h.gv_get_field_type_schema({ field_type: 'text' }); + const resp = await h.gv_field_type_schema_get({ field_type: 'text' }); const showLabel = schemaItem(resp.schema, 'show_label'); TestAssert.isNotNull(showLabel, 'show_label present'); const hide = showLabel.requires?.hide; @@ -371,7 +371,7 @@ suite.test('Shape: requires envelope uses show/hide sub-keys; single-condition c suite.test('Shape: multi-condition rule keeps Query Filters group wrapper', async () => { if (suite.skip) return; - const resp = await h.gv_get_field_type_schema({ field_type: 'text' }); + const resp = await h.gv_field_type_schema_get({ field_type: 'text' }); const customLabel = schemaItem(resp.schema, 'custom_label'); TestAssert.isNotNull(customLabel); const show = customLabel.requires?.show; @@ -383,7 +383,7 @@ suite.test('Shape: multi-condition rule keeps Query Filters group wrapper', asyn suite.test('Shape: synthetic _id hashes stripped from parsed rules', async () => { if (suite.skip) return; - const resp = await h.gv_get_field_type_schema({ field_type: 'text' }); + const resp = await h.gv_field_type_schema_get({ field_type: 'text' }); const hasSynthetic = treeHasKey( resp.schema, '_id', @@ -394,7 +394,7 @@ suite.test('Shape: synthetic _id hashes stripped from parsed rules', async () => suite.test('Shape: desc HTML stripped to plain text', async () => { if (suite.skip) return; - const resp = await h.gv_get_field_type_schema({ field_type: 'text' }); + const resp = await h.gv_field_type_schema_get({ field_type: 'text' }); const slot = schemaItem(resp.schema, 'conditional_logic_container'); if (!slot) return; const desc = slot.desc || ''; @@ -406,7 +406,7 @@ suite.test('Shape: desc HTML stripped to plain text', async () => { suite.test('Shape: empty values (null / "" / []) dropped from schema items', async () => { if (suite.skip) return; - const resp = await h.gv_get_field_type_schema({ field_type: 'text' }); + const resp = await h.gv_field_type_schema_get({ field_type: 'text' }); for (const item of resp.schema || []) { for (const [k, v] of Object.entries(item)) { const isEmpty = v === null || v === '' || (Array.isArray(v) && v.length === 0); @@ -422,7 +422,7 @@ suite.test('Shape: empty values (null / "" / []) dropped from schema items', asy suite.test('Shape: apply default returns compact {view_id, version, applied}', async () => { if (suite.skip) return; const viewId = await mintView('compact apply'); - const apply = await h.gv_apply_view_config({ + const apply = await h.gv_view_config_apply({ id: viewId, fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'cmpct001' }] }, mode: 'merge', @@ -443,7 +443,7 @@ suite.test('Shape: apply default returns compact {view_id, version, applied}', a suite.test('Shape: create_field_slot response uses `slot` not legacy `slot_uid`', async () => { if (suite.skip) return; const viewId = await mintView('slot-alias'); - const created = await h.gv_add_view_field({ + const created = await h.gv_view_field_add({ id: viewId, area: 'directory_list-title', field_id: fieldIds.name, @@ -459,7 +459,7 @@ suite.test('Shape: create_field_slot response uses `slot` not legacy `slot_uid`' suite.test('Shape: version timestamp is real (not 1970 Unix epoch)', async () => { if (suite.skip) return; const viewId = await mintView('version timestamp'); - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); TestAssert.isTrue(!config.version.includes('1970-01-01T00:00:00Z'), 'epoch sentinel must not appear'); TestAssert.isTrue( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z:\d+$/.test(config.version), @@ -475,7 +475,7 @@ suite.test('Round-trip: bulk apply preserves diverse settings verbatim', async ( if (suite.skip) return; const viewId = await mintView('round-trip bulk'); - const apply = await h.gv_apply_view_config({ + const apply = await h.gv_view_config_apply({ id: viewId, fields: { 'directory_list-title': [ @@ -511,7 +511,7 @@ suite.test('Round-trip: bulk apply preserves diverse settings verbatim', async ( }); TestAssert.isNotNull(apply.applied); - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); const titleSlot = config.fields['directory_list-title']['rtname001']; TestAssert.equal(titleSlot.custom_label, 'Speaker Name'); TestAssert.equal(titleSlot.custom_class, 'speaker-name big'); @@ -559,7 +559,7 @@ suite.test('Round-trip: move_field preserves slot UID + settings across areas', if (suite.skip) return; const viewId = await mintView('move preserves uid'); - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, fields: { 'directory_list-title': [{ @@ -572,13 +572,13 @@ suite.test('Round-trip: move_field preserves slot UID + settings across areas', mode: 'merge', }); - await h.gv_move_view_field({ + await h.gv_view_field_move({ id: viewId, from: { area: 'directory_list-title', slot: 'rtmove001' }, to: { area: 'directory_list-subtitle' }, }); - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); TestAssert.isTrue( !(config.fields['directory_list-title'] || {})['rtmove001'], 'source area no longer holds the slot' @@ -652,7 +652,7 @@ suite.test('Sanitisation: custom content keeps full HTML body', async () => { suite.test('Sanitisation: numeric values coerce to int regardless of mode', async () => { if (suite.skip) return; const viewId = await mintView('numeric coerce'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); const stored = await roundTripSlot(viewId, 'directory_table-columns', 'sanitnum001', { field_id: fieldIds.name, width: '50', @@ -730,13 +730,13 @@ suite.test('Concurrency: parallel writes with same ETag → 1 accepted, rest rej // Read once to capture the starting ETag, then fire N parallel // applies with the same If-Match. Server's GET_LOCK + counter // bump means exactly ONE should accept. - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); const etag = `"${config.version}"`; const N = 5; const results = await Promise.allSettled( Array.from({ length: N }, (_, i) => - h.gv_apply_view_config({ + h.gv_view_config_apply({ id: viewId, fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: `conc${String(i).padStart(3, '0')}` }] }, mode: 'merge', @@ -786,7 +786,7 @@ suite.test('Preview stage: POST returns 32-hex key, DELETE clears, ownership enf suite.test('Warnings: valid conditional_logic doc → no warnings in apply response', async () => { if (suite.skip) return; const viewId = await mintView('cl valid no warnings'); - const apply = await h.gv_apply_view_config({ + const apply = await h.gv_view_config_apply({ id: viewId, fields: { 'directory_list-title': [{ @@ -806,7 +806,7 @@ suite.test('Warnings: valid conditional_logic doc → no warnings in apply respo suite.test('Warnings: CL missing version → reason=missing_version, value dropped', async () => { if (suite.skip) return; const viewId = await mintView('cl missing version'); - const apply = await h.gv_apply_view_config({ + const apply = await h.gv_view_config_apply({ id: viewId, fields: { 'directory_list-title': [{ @@ -829,7 +829,7 @@ suite.test('Warnings: CL missing version → reason=missing_version, value dropp TestAssert.isNotNull(match, 'warning carries area/slot/key/reason=missing_version'); // Confirm the value was actually dropped from the persisted slot. - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); const stored = config.fields['directory_list-title']['clbad001']; TestAssert.isTrue( !stored.conditional_logic || stored.conditional_logic === '', @@ -848,9 +848,9 @@ suite.test('Widget create: persists every payload setting beyond id+label', asyn // default_table has stable static widget zones (header_top / // footer_top) — using it instead of Layout Builder lets the // test target a known area without first creating grid rows. - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); - const created = await h.gv_add_view_widget({ + const created = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { @@ -868,7 +868,7 @@ suite.test('Widget create: persists every payload setting beyond id+label', asyn // Confirm GET /config sees the same settings (proves persistence, // not just response shape). - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); const slot = config.widgets?.header_top?.[created.slot]; TestAssert.isNotNull(slot, 'widget slot persisted in config'); TestAssert.equal(slot.id, 'custom_content'); @@ -880,9 +880,9 @@ suite.test('Widget create: persists every payload setting beyond id+label', asyn suite.test('Widget create: search_bar payload auto-migrates to modern shape', async () => { if (suite.skip) return; const viewId = await mintView('widget create search_bar modern'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); - const created = await h.gv_add_view_widget({ + const created = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { @@ -911,11 +911,11 @@ suite.test('Widget create: search_bar payload auto-migrates to modern shape', as suite.test('Render: unknown slot WITHOUT staged_slot returns 404', async () => { if (suite.skip) return; const viewId = await mintView('render no staged'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); let status = null; try { - await h.gv_render_view_field({ + await h.gv_view_field_render({ id: viewId, area: 'directory_table-columns', slot: 'never0001', @@ -929,7 +929,7 @@ suite.test('Render: unknown slot WITHOUT staged_slot returns 404', async () => { suite.test('Render: staged_slot synthesizes an unsaved slot for preview', async () => { if (suite.skip) return; const viewId = await mintView('render staged unsaved'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); // Render with `staged_slot` carrying field_id + a custom_label — // server should synthesize a slot record and run the production @@ -937,7 +937,7 @@ suite.test('Render: staged_slot synthesizes an unsaved slot for preview', async // from the existing /render endpoint contract. let result; try { - result = await h.gv_render_view_field({ + result = await h.gv_view_field_render({ id: viewId, area: 'directory_table-columns', slot: 'staged0001', @@ -970,10 +970,10 @@ suite.test('Render: staged_slot synthesizes an unsaved slot for preview', async suite.test('Render: settings override on saved slot still works (regression)', async () => { if (suite.skip) return; const viewId = await mintView('render settings override'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); // First save a real slot. - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, fields: { 'directory_table-columns': [{ field_id: fieldIds.name, slot: 'saved001' }], @@ -984,7 +984,7 @@ suite.test('Render: settings override on saved slot still works (regression)', a // Then render with a settings override. Should not 404. let status = null; try { - await h.gv_render_view_field({ + await h.gv_view_field_render({ id: viewId, area: 'directory_table-columns', slot: 'saved001', @@ -1005,7 +1005,7 @@ suite.test('Render: settings override on saved slot still works (regression)', a suite.test('Search input types: GET /search-fields/input-types returns canonical core slugs', async () => { if (suite.skip) return; - const { input_types } = await h.gv_list_search_input_types({}); + const { input_types } = await h.gv_search_input_types_list({}); TestAssert.isTrue(Array.isArray(input_types), 'input_types is an array'); // The core list must contain at minimum these slugs. Add-ons may // contribute more via the gravityview/search/input_labels filter, @@ -1035,8 +1035,8 @@ suite.test('Search input types: client pre-flight accepts valid slug', async () suite.test('Search input types: server rejects typo with 400 + helpful error', async () => { if (suite.skip) return; const viewId = await mintView('search input server reject'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, @@ -1049,7 +1049,7 @@ suite.test('Search input types: server rejects typo with 400 + helpful error', a let serverStatus = null; let serverMessage = ''; try { - await h.gv_add_search_field({ + await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, @@ -1088,7 +1088,7 @@ suite.test('Search input types: server rejects typo with 400 + helpful error', a suite.test('Template settings schema: core source always exposes shared keys (page_size etc.)', async () => { if (suite.skip) return; - const { schema, template_id } = await h.gv_get_template_settings_schema({ template_id: 'default_table' }); + const { schema, template_id } = await h.gv_template_settings_schema_get({ template_id: 'default_table' }); TestAssert.equal(template_id, 'default_table'); TestAssert.isTrue(Array.isArray(schema) && schema.length > 0, 'schema array non-empty'); const slugs = new Set(schema.map((it) => it?.slug)); @@ -1101,8 +1101,8 @@ suite.test('Mock source: schema exposes dotted slugs gated by template_ids', asy return; } // mockone is gated on default_table — should appear there + nowhere else. - const onTable = await h.gv_get_template_settings_schema({ template_id: 'default_table' }); - const onList = await h.gv_get_template_settings_schema({ template_id: 'default_list' }); + const onTable = await h.gv_template_settings_schema_get({ template_id: 'default_table' }); + const onList = await h.gv_template_settings_schema_get({ template_id: 'default_list' }); const tableSlugs = new Set(onTable.schema.map((it) => it?.slug)); const listSlugs = new Set(onList.schema.map((it) => it?.slug)); @@ -1128,7 +1128,7 @@ suite.test('Mock source: core source dedupes entries claimed by silo `groups`', // those slugs, the dedupe logic must still ensure the core source // emits NOTHING with those group values regardless of how the // catalog grows. Belt-and-braces check. - const { schema } = await h.gv_get_template_settings_schema({ template_id: 'default_table' }); + const { schema } = await h.gv_template_settings_schema_get({ template_id: 'default_table' }); for (const it of schema) { if ((it?.group ?? '') === 'mock_silo' && !(it?.slug ?? '').startsWith('mockone.')) { throw new Error(`core source emitted undotted slug "${it.slug}" for silo'd group "mock_silo"`); @@ -1143,9 +1143,9 @@ suite.test('Mock source: PATCH /template-settings routes nested writes to the ri return; } const viewId = await mintView('mock silo round-trip'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); - await h.gv_patch_view_settings({ + await h.gv_view_settings_patch({ id: viewId, template_settings: { mockone: { foo: 'hello', bar: '42', content: '

html ok

' }, @@ -1153,7 +1153,7 @@ suite.test('Mock source: PATCH /template-settings routes nested writes to the ri }, }); - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); TestAssert.equal(String(config.template_settings.page_size), '99', 'top-level page_size on core meta'); TestAssert.isTrue( config.template_settings.mockone && typeof config.template_settings.mockone === 'object', @@ -1174,9 +1174,9 @@ suite.test('Mock source: /apply also splits namespaced writes to silo meta', asy return; } const viewId = await mintView('mock silo apply path'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, template_settings: { page_size: '25', @@ -1185,7 +1185,7 @@ suite.test('Mock source: /apply also splits namespaced writes to silo meta', asy mode: 'merge', }); - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); TestAssert.equal(String(config.template_settings.page_size), '25', 'core key persisted via apply'); TestAssert.equal(config.template_settings.mockone?.foo, 'via-apply', 'silo key persisted via apply'); }); @@ -1197,21 +1197,21 @@ suite.test('Mock source: keys NOT in the partial payload survive the merge', asy return; } const viewId = await mintView('mock silo non-overlap merge'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); // Seed two keys on the silo, then patch only one — the other // must remain. This proves the per-source bucket-seeded merge // doesn't blow away unmentioned silo keys. - await h.gv_patch_view_settings({ + await h.gv_view_settings_patch({ id: viewId, template_settings: { mockone: { foo: 'first', bar: '7' } }, }); - await h.gv_patch_view_settings({ + await h.gv_view_settings_patch({ id: viewId, template_settings: { mockone: { foo: 'second' } }, }); - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); TestAssert.equal(config.template_settings.mockone?.foo, 'second', 'updated key takes new value'); TestAssert.equal(Number(config.template_settings.mockone?.bar), 7, 'untouched key survives merge'); }); @@ -1224,7 +1224,7 @@ suite.test('CL with leading whitespace is accepted (trim bug)', async () => { if (suite.skip) return; const viewId = await mintView('cl trim bug'); const padded = ' ' + JSON.stringify({ version: 2, actionType: 'show', logicType: 'all', rules: [] }) + ' \n'; - const apply = await h.gv_apply_view_config({ + const apply = await h.gv_view_config_apply({ id: viewId, fields: { 'directory_list-title': [{ @@ -1240,7 +1240,7 @@ suite.test('CL with leading whitespace is accepted (trim bug)', async () => { 'no warning emitted — padded JSON was trimmed and accepted' ); - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); const stored = config.fields['directory_list-title']['cltrim001'].conditional_logic; TestAssert.isTrue( typeof stored === 'string' && stored.startsWith('{') && stored.endsWith('}'), @@ -1251,8 +1251,8 @@ suite.test('CL with leading whitespace is accepted (trim bug)', async () => { suite.test('Per-field narrowing rejects date_range on search_mode', async () => { if (suite.skip) return; const viewId = await mintView('per-field narrow search_mode'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, @@ -1261,7 +1261,7 @@ suite.test('Per-field narrowing rejects date_range on search_mode', async () => let serverStatus = null; let serverMessage = ''; try { - await h.gv_add_search_field({ + await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, @@ -1283,15 +1283,15 @@ suite.test('Per-field narrowing rejects date_range on search_mode', async () => suite.test('Per-field narrowing accepts hidden on search_mode', async () => { if (suite.skip) return; const viewId = await mintView('per-field narrow search_mode ok'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); // `hidden` IS valid for search_mode — should succeed. - const created = await h.gv_add_search_field({ + const created = await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, @@ -1304,12 +1304,12 @@ suite.test('Per-field narrowing accepts hidden on search_mode', async () => { suite.test('create_widget_slot rejects nested invalid search_fields_section', async () => { if (suite.skip) return; const viewId = await mintView('widget nested search reject'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); let serverStatus = null; let serverMessage = ''; try { - await h.gv_add_view_widget({ + await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { @@ -1334,7 +1334,7 @@ suite.test('create_widget_slot rejects nested invalid search_fields_section', as suite.test('Warnings: CL string that isn\'t a JSON object → reason=not_json_object', async () => { if (suite.skip) return; const viewId = await mintView('cl not json object'); - const apply = await h.gv_apply_view_config({ + const apply = await h.gv_view_config_apply({ id: viewId, fields: { 'directory_list-title': [{ @@ -1363,8 +1363,8 @@ suite.test('Area keys: gv_create_grid_row returns ready-to-use prefixed area_key if (suite.skip) return; const viewId = await mintView('area_keys contract'); // Layout Builder is the only grid-aware template by default. - await h.gv_patch_view_template({ id: viewId, template_id: 'gravityview-layout-builder' }); - const row = await h.gv_add_grid_row({ + await h.gv_view_template_switch({ id: viewId, template_id: 'gravityview-layout-builder' }); + const row = await h.gv_grid_row_add({ id: viewId, type: '25/25/25/25', zones: ['directory'], @@ -1383,13 +1383,13 @@ suite.test('Area keys: gv_create_grid_row returns ready-to-use prefixed area_key suite.test('Area keys: apply_view_config REJECTS a fields area key missing the zone prefix', async () => { if (suite.skip) return; const viewId = await mintView('reject unprefixed'); - await h.gv_patch_view_template({ id: viewId, template_id: 'gravityview-layout-builder' }); - const row = await h.gv_add_grid_row({ id: viewId, type: '100', zones: ['directory'] }); + await h.gv_view_template_switch({ id: viewId, template_id: 'gravityview-layout-builder' }); + const row = await h.gv_grid_row_add({ id: viewId, type: '100', zones: ['directory'] }); // Use the LEGACY unprefixed key (this is the exact bug the demo hit). const badKey = `gravityview-layout-builder-top::100::${row.row_uid}`; let status = null, code = null; try { - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', fields: { [badKey]: [{ field_id: fieldIds.name, slot: 'should_fail' }] }, @@ -1407,7 +1407,7 @@ suite.test('Area keys: apply_view_config REJECTS a bogus widget area key', async const viewId = await mintView('reject bogus widget area'); let status = null, code = null; try { - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', widgets: { 'not_a_real_zone': [{ field_id: 'search_bar', slot: 'x' }] }, @@ -1423,12 +1423,12 @@ suite.test('Area keys: apply_view_config REJECTS a bogus widget area key', async suite.test('Area keys: prefixed keys round-trip end-to-end (create-row → use area_keys → read-back)', async () => { if (suite.skip) return; const viewId = await mintView('e2e prefixed roundtrip'); - await h.gv_patch_view_template({ id: viewId, template_id: 'gravityview-layout-builder' }); - const row = await h.gv_add_grid_row({ id: viewId, type: '50/50', zones: ['directory'] }); + await h.gv_view_template_switch({ id: viewId, template_id: 'gravityview-layout-builder' }); + const row = await h.gv_grid_row_add({ id: viewId, type: '50/50', zones: ['directory'] }); TestAssert.equal(row.area_keys.length, 2); // Use the API's own returned keys verbatim — the test the demo would have passed. - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', fields: { @@ -1437,7 +1437,7 @@ suite.test('Area keys: prefixed keys round-trip end-to-end (create-row → use a }, }); - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); TestAssert.isNotNull(cfg.fields[row.area_keys[0]]?.rt_a, 'first area has its slot after apply'); TestAssert.isNotNull(cfg.fields[row.area_keys[1]]?.rt_b, 'second area has its slot after apply'); @@ -1451,7 +1451,7 @@ suite.test('Area keys: prefixed keys round-trip end-to-end (create-row → use a suite.test('Inspector shape: template_ids contains directory + single ONLY (no edit)', async () => { if (suite.skip) return; const viewId = await mintView('template_ids no edit'); - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); TestAssert.isNotNull(cfg.template_ids?.directory, 'template_ids.directory present'); TestAssert.isNotNull(cfg.template_ids?.single, 'template_ids.single present'); TestAssert.isTrue( @@ -1463,8 +1463,8 @@ suite.test('Inspector shape: template_ids contains directory + single ONLY (no e suite.test('Inspector shape: template_settings does NOT carry the legacy `template` key', async () => { if (suite.skip) return; const viewId = await mintView('no legacy template key'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const cfg = await h.gv_get_view_config({ id: viewId }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const cfg = await h.gv_view_config_get({ id: viewId }); TestAssert.isTrue( !('template' in (cfg.template_settings || {})), 'template_settings.template stripped — canonical store is template_ids.directory', @@ -1474,9 +1474,9 @@ suite.test('Inspector shape: template_settings does NOT carry the legacy `templa suite.test('Inspector shape: template_settings stays clean even after a template switch', async () => { if (suite.skip) return; const viewId = await mintView('template switch no leak'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - await h.gv_patch_view_template({ id: viewId, template_id: 'gravityview-layout-builder' }); - const cfg = await h.gv_get_view_config({ id: viewId }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'gravityview-layout-builder' }); + const cfg = await h.gv_view_config_get({ id: viewId }); TestAssert.isTrue( !('template' in (cfg.template_settings || {})), 'template_settings.template still absent after switch', @@ -1497,16 +1497,16 @@ suite.test('Inspector shape: template_settings stays clean even after a template // so storage carries the canonical shape regardless of who wrote it. // ============================================================ -suite.test('Search field shape: gv_add_search_field emits the domain canonical shape', async () => { +suite.test('Search field shape: gv_search_field_add emits the domain canonical shape', async () => { if (suite.skip) return; const viewId = await mintView('search field canonical shape'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); - const created = await h.gv_add_search_field({ + const created = await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, @@ -1514,7 +1514,7 @@ suite.test('Search field shape: gv_add_search_field emits the domain canonical s field: { id: fieldIds.name, input: 'input_text', label: 'Speaker' }, }); - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const stored = cfg.widgets.header_top[widget.slot].search_fields_section[ 'search-general_top::100::canonshape_row' ][created.search_slot]; @@ -1540,13 +1540,13 @@ suite.test('Search field shape: gv_add_search_field emits the domain canonical s suite.test('Search field shape: GF field carries `type`={form_id}::{field_id} + form_field object', async () => { if (suite.skip) return; const viewId = await mintView('search field gf canonical type'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); - await h.gv_add_search_field({ + await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, @@ -1554,7 +1554,7 @@ suite.test('Search field shape: GF field carries `type`={form_id}::{field_id} + field: { id: fieldIds.email, input: 'input_text', label: 'Email' }, }); - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const slot = Object.values( cfg.widgets.header_top[widget.slot].search_fields_section['search-general_top::100::gftype_row'], )[0]; @@ -1571,19 +1571,19 @@ suite.test('Search field shape: GF field carries `type`={form_id}::{field_id} + TestAssert.isTrue('id' in slot.form_field && 'type' in slot.form_field, 'form_field carries the GF field shape'); }); -suite.test('Search field shape: bulk apply (gv_apply_view_config) routes nested entries through the domain too', async () => { +suite.test('Search field shape: bulk apply (gv_view_config_apply) routes nested entries through the domain too', async () => { if (suite.skip) return; const viewId = await mintView('bulk normalise search section'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); - // Bulk-apply a nested search_fields_section through gv_apply_view_config — + // Bulk-apply a nested search_fields_section through gv_view_config_apply — // entries SHOULD pass through Search_Field::from_configuration → to_configuration // just like the per-field CRUD path does. - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', widgets: { @@ -1600,7 +1600,7 @@ suite.test('Search field shape: bulk apply (gv_apply_view_config) routes nested }, }); - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const stored = cfg.widgets.header_top[widget.slot].search_fields_section[ 'search-general_top::100::bulkrow' ].bulkfield; @@ -1645,13 +1645,13 @@ suite.test('Search field round-trip: fields survive a legacy WP save_post (bug-c } const viewId = await mintView('search field legacy roundtrip'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); - await h.gv_add_search_field({ + await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, @@ -1664,7 +1664,7 @@ suite.test('Search field round-trip: fields survive a legacy WP save_post (bug-c // hook chain end-to-end against the live WP install. cp.execSync(`cd ${wpRoot} && wp post update ${viewId} --post_modified="$(date -u +'%Y-%m-%d %H:%M:%S')"`, { stdio: 'pipe' }); - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const section = cfg.widgets?.header_top?.[widget.slot]?.search_fields_section; TestAssert.isNotNull(section, 'search_fields_section present after legacy save'); const row = section['search-general_top::100::roundtrip_row']; @@ -1695,7 +1695,7 @@ function errMessage(err) { /** Run an apply that we expect to throw; return the captured error info. */ async function expectApplyError(args) { try { - const result = await h.gv_apply_view_config(args); + const result = await h.gv_view_config_apply(args); return { thrown: false, result }; } catch (err) { return { @@ -1712,13 +1712,13 @@ async function expectApplyError(args) { suite.test('[hostile] Concurrency: 10 parallel applies with same If-Match → exactly 1 wins', async () => { if (suite.skip) return; const viewId = await mintView('hostile-conc-10'); - const config = await h.gv_get_view_config({ id: viewId }); + const config = await h.gv_view_config_get({ id: viewId }); const etag = `"${config.version}"`; const N = 10; const results = await Promise.allSettled( Array.from({ length: N }, (_, i) => - h.gv_apply_view_config({ + h.gv_view_config_apply({ id: viewId, ifMatch: etag, mode: 'merge', @@ -1732,12 +1732,12 @@ suite.test('[hostile] Concurrency: 10 parallel applies with same If-Match → ex TestAssert.equal(rejected, N - 1, `expected ${N - 1} 412 rejections, got ${rejected}`); }); -suite.test('[hostile] Concurrency: 50 parallel reads of gv_get_view_config all succeed', async () => { +suite.test('[hostile] Concurrency: 50 parallel reads of gv_view_config_get all succeed', async () => { if (suite.skip) return; const viewId = await mintView('hostile-50-reads'); const N = 50; const results = await Promise.allSettled( - Array.from({ length: N }, () => h.gv_get_view_config({ id: viewId })) + Array.from({ length: N }, () => h.gv_view_config_get({ id: viewId })) ); const ok = results.filter((r) => r.status === 'fulfilled' && r.value?.view_id === viewId).length; TestAssert.equal(ok, N, `all ${N} reads should succeed, got ${ok}`); @@ -1753,12 +1753,12 @@ suite.test('[hostile] Concurrency: 20 interleaved apply+read pairs — no torn r // unconditionally so we can assert each one is visible immediately). for (let i = 0; i < N; i++) { const slotUid = `tear${String(i).padStart(3, '0')}`; - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: slotUid, custom_label: `Iter ${i}` }] }, }); - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const slot = cfg.fields?.['directory_list-title']?.[slotUid]; TestAssert.isNotNull(slot, `iteration ${i}: slot ${slotUid} must be present in subsequent read`); TestAssert.equal(slot.custom_label, `Iter ${i}`, `iteration ${i}: custom_label torn`); @@ -1768,18 +1768,18 @@ suite.test('[hostile] Concurrency: 20 interleaved apply+read pairs — no torn r suite.test('[hostile] Concurrency: two writers racing on different slots in same area both land', async () => { if (suite.skip) return; const viewId = await mintView('hostile-two-writers'); - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const etag = `"${cfg.version}"`; // Same etag, but distinct slot UIDs in the same area. const results = await Promise.allSettled([ - h.gv_apply_view_config({ + h.gv_view_config_apply({ id: viewId, ifMatch: etag, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'race_a' }] }, }), - h.gv_apply_view_config({ + h.gv_view_config_apply({ id: viewId, ifMatch: etag, mode: 'merge', @@ -1792,12 +1792,12 @@ suite.test('[hostile] Concurrency: two writers racing on different slots in same TestAssert.equal(accepted, 1, 'optimistic concurrency: only 1 same-etag write lands per round'); // Now retry the rejected write WITHOUT ifMatch (the docs say omit = bypass). - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.email, slot: 'race_b' }] }, }); - const final = await h.gv_get_view_config({ id: viewId }); + const final = await h.gv_view_config_get({ id: viewId }); TestAssert.isNotNull(final.fields?.['directory_list-title']?.race_a, 'race_a landed'); TestAssert.isNotNull(final.fields?.['directory_list-title']?.race_b, 'race_b landed after retry without ifMatch'); }); @@ -1816,7 +1816,7 @@ suite.test('[hostile] Huge payload: 200 fields in a single apply', async () => { const t0 = Date.now(); let result, err; try { - result = await h.gv_apply_view_config({ + result = await h.gv_view_config_apply({ id: viewId, mode: 'merge', fields: { 'directory_list-title': slots }, @@ -1835,7 +1835,7 @@ suite.test('[hostile] Huge payload: 200 fields in a single apply', async () => { } TestAssert.isNotNull(result.applied, '200-field apply succeeded'); // Spot-check the round trip — pick start/end/middle. - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const titleArea = cfg.fields?.['directory_list-title'] || {}; TestAssert.isNotNull(titleArea.huge0000, 'first slot present'); TestAssert.isNotNull(titleArea.huge0099, 'middle slot present'); @@ -1883,15 +1883,15 @@ suite.test('[hostile] Huge payload: conditional_logic with 100 nested rules', as suite.test('[hostile] Huge payload: search bar with 50 search fields across 5 positions', async () => { if (suite.skip) return; const viewId = await mintView('hostile-50-search-fields'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); const positions = ['10', '20', '30', '40', '50']; for (const pos of positions) { for (let i = 0; i < 10; i++) { try { - await h.gv_add_search_field({ + await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, position: `search-general_top::${pos}::row_${pos}`, slot: `s_${pos}_${i}`, @@ -1902,7 +1902,7 @@ suite.test('[hostile] Huge payload: search bar with 50 search fields across 5 po } } } - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const section = cfg.widgets?.header_top?.[widget.slot]?.search_fields_section || {}; let total = 0; for (const row of Object.values(section)) total += Object.keys(row || {}).length; @@ -2012,7 +2012,7 @@ suite.test('[hostile] Malformed: ifMatch = SQL injection attempt → safe reject TestAssert.isTrue(r.thrown, 'malicious ifMatch must reject'); TestAssert.isTrue(r.status >= 400 && r.status < 500, `must be 4xx, got ${r.status}: ${r.message}`); // Confirm the view is still alive afterwards (no DB damage). - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); TestAssert.equal(cfg.view_id, viewId, 'view still readable after SQLi attempt'); }); @@ -2048,7 +2048,7 @@ suite.test('[hostile] Malformed: slot uid with path traversal "../../../etc/pass // Server should normalise / reject. If it accepts, ensure no filesystem // surprise — the slot must just be a string key, not a real path. if (!r.thrown) { - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const area = cfg.fields?.['directory_list-title'] || {}; const keys = Object.keys(area); TestAssert.isTrue(keys.length > 0, 'something stored'); @@ -2099,7 +2099,7 @@ suite.test('[hostile] Edge: field_id = "0" (zero) → either reject or treat as TestAssert.isTrue(r.status >= 400 && r.status < 500, `field_id 0 reject must be 4xx (got ${r.status})`); } // If accepted, view must still be readable. - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); TestAssert.equal(cfg.view_id, viewId, 'view readable after field_id=0'); }); @@ -2130,7 +2130,7 @@ suite.test('[hostile] Edge: field_id = "1.2.3.4" (IP-like) → no 5xx', async () suite.test('[hostile] CL: invalid JSON string → warning, value dropped, no 5xx', async () => { if (suite.skip) return; const viewId = await mintView('hostile-cl-bad-json'); - const apply = await h.gv_apply_view_config({ + const apply = await h.gv_view_config_apply({ id: viewId, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'clbj01', @@ -2145,7 +2145,7 @@ suite.test('[hostile] CL: invalid JSON string → warning, value dropped, no 5xx suite.test('[hostile] CL: valid JSON of wrong shape (e.g. array) → warning, value dropped', async () => { if (suite.skip) return; const viewId = await mintView('hostile-cl-wrong-shape'); - const apply = await h.gv_apply_view_config({ + const apply = await h.gv_view_config_apply({ id: viewId, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'clws01', @@ -2184,13 +2184,13 @@ suite.test('[hostile] Unicode: emoji-only custom_label', async () => { suite.test('[hostile] Search input: leading/trailing whitespace (" input_text ") → reject or trim cleanly', async () => { if (suite.skip) return; const viewId = await mintView('hostile-search-whitespace'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); let status = null; try { - await h.gv_add_search_field({ + await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, position: 'search-general_top::100::ws_row', field: { id: fieldIds.name, input: ' input_text ', label: 'WS' }, @@ -2206,13 +2206,13 @@ suite.test('[hostile] Search input: leading/trailing whitespace (" input_text suite.test('[hostile] Search input: wrong-case ("INPUT_TEXT") → reject or normalise', async () => { if (suite.skip) return; const viewId = await mintView('hostile-search-case'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); let status = null; try { - await h.gv_add_search_field({ + await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, position: 'search-general_top::100::case_row', field: { id: fieldIds.name, input: 'INPUT_TEXT', label: 'CASE' }, @@ -2226,13 +2226,13 @@ suite.test('[hostile] Search input: wrong-case ("INPUT_TEXT") → reject or norm suite.test('[hostile] Search input: numeric (1) instead of string', async () => { if (suite.skip) return; const viewId = await mintView('hostile-search-numeric-input'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); let status = null; try { - await h.gv_add_search_field({ + await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, position: 'search-general_top::100::num_row', field: { id: fieldIds.name, input: 1, label: 'NUM' }, @@ -2246,13 +2246,13 @@ suite.test('[hostile] Search input: numeric (1) instead of string', async () => suite.test('[hostile] Search field: field.id = 0 → reject', async () => { if (suite.skip) return; const viewId = await mintView('hostile-searchfield-zero'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); let status = null; try { - await h.gv_add_search_field({ + await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, position: 'search-general_top::100::z_row', field: { id: 0, input: 'input_text', label: 'Z' }, @@ -2266,13 +2266,13 @@ suite.test('[hostile] Search field: field.id = 0 → reject', async () => { suite.test('[hostile] Search field: field.id pointing at a deleted/non-existent GF field', async () => { if (suite.skip) return; const viewId = await mintView('hostile-searchfield-ghost'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - const widget = await h.gv_add_view_widget({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, }); let status = null; try { - await h.gv_add_search_field({ + await h.gv_search_field_add({ id: viewId, widget_area: 'header_top', widget_slot: widget.slot, position: 'search-general_top::100::g_row', field: { id: 99999, input: 'input_text', label: 'Ghost' }, @@ -2290,10 +2290,10 @@ suite.test('[hostile] Search field: field.id pointing at a deleted/non-existent suite.test('[hostile] Widget: bogus widget id (definitely_not_real) → reject', async () => { if (suite.skip) return; const viewId = await mintView('hostile-widget-bogus-id'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); let status = null; try { - await h.gv_add_view_widget({ + await h.gv_view_widget_add({ id: viewId, area: 'header_top', widget: { field_id: 'definitely_not_a_real_widget', label: 'X' }, }); @@ -2309,11 +2309,11 @@ suite.test('[hostile] area_settings injected as a "field" must NOT be treated as // area_settings is a meta key on the area itself, not a slot. // A slot literally named "area_settings" should either reject or be // namespaced so it doesn't clobber real area_settings. - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'area_settings' }] }, }); - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); // If "area_settings" got stored as a real slot key, it MUST have a field_id — // otherwise it's been confused with the area-level settings envelope. const stored = cfg.fields?.['directory_list-title']?.area_settings; @@ -2330,10 +2330,10 @@ suite.test('[hostile] area_settings injected as a "field" must NOT be treated as suite.test('[hostile] template-settings: page_size = 0 → coerced or rejected, no 5xx', async () => { if (suite.skip) return; const viewId = await mintView('hostile-pagesize-0'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); let err; try { - await h.gv_patch_view_settings({ + await h.gv_view_settings_patch({ id: viewId, template_settings: { page_size: 0 }, }); } catch (e) { err = e; } @@ -2345,10 +2345,10 @@ suite.test('[hostile] template-settings: page_size = 0 → coerced or rejected, suite.test('[hostile] template-settings: page_size = -1 → coerced or rejected', async () => { if (suite.skip) return; const viewId = await mintView('hostile-pagesize-neg'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); let err; try { - await h.gv_patch_view_settings({ + await h.gv_view_settings_patch({ id: viewId, template_settings: { page_size: -1 }, }); } catch (e) { err = e; } @@ -2360,17 +2360,17 @@ suite.test('[hostile] template-settings: page_size = -1 → coerced or rejected' suite.test('[hostile] template-settings: page_size = "abc" → 4xx or coerced to 0/default', async () => { if (suite.skip) return; const viewId = await mintView('hostile-pagesize-string'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); let err; try { - await h.gv_patch_view_settings({ + await h.gv_view_settings_patch({ id: viewId, template_settings: { page_size: 'abc' }, }); } catch (e) { err = e; } if (err) { TestAssert.isTrue(errStatus(err) >= 400 && errStatus(err) < 500, `page_size="abc" reject must 4xx, got ${errStatus(err)}: ${errMessage(err)}`); } else { - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const ps = cfg.template_settings?.page_size; TestAssert.isTrue( ps === undefined || ps === null || ps === 0 || /^\d+$/.test(String(ps)), @@ -2382,12 +2382,12 @@ suite.test('[hostile] template-settings: page_size = "abc" → 4xx or coerced to suite.test('[hostile] template-settings: 100 keys at once', async () => { if (suite.skip) return; const viewId = await mintView('hostile-100-template-keys'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); const ts = {}; for (let i = 0; i < 100; i++) ts[`unknown_key_${i}`] = `v${i}`; let err; try { - await h.gv_patch_view_settings({ id: viewId, template_settings: ts }); + await h.gv_view_settings_patch({ id: viewId, template_settings: ts }); } catch (e) { err = e; } if (err) { TestAssert.isTrue(errStatus(err) >= 400 && errStatus(err) < 500, `bulk-template-settings must 4xx, got ${errStatus(err)}`); @@ -2399,11 +2399,11 @@ suite.test('[hostile] template-settings: 100 keys at once', async () => { suite.test('[hostile] Render: 50 parallel staged_slot renders', async () => { if (suite.skip) return; const viewId = await mintView('hostile-render-50-parallel'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); const N = 50; const t0 = Date.now(); const results = await Promise.allSettled( - Array.from({ length: N }, (_, i) => h.gv_render_view_field({ + Array.from({ length: N }, (_, i) => h.gv_view_field_render({ id: viewId, area: 'directory_table-columns', slot: `parallel${String(i).padStart(3, '0')}`, staged_slot: { field_id: fieldIds.name, custom_label: `Parallel ${i}`, show_label: '1' }, @@ -2424,11 +2424,11 @@ suite.test('[hostile] Render: 50 parallel staged_slot renders', async () => { suite.test('[hostile] Render: extremely long custom_label (10KB) — no 5xx', async () => { if (suite.skip) return; const viewId = await mintView('hostile-render-long-label'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); const longLabel = 'L'.repeat(10000); let status = null; try { - await h.gv_render_view_field({ + await h.gv_view_field_render({ id: viewId, area: 'directory_table-columns', slot: 'longlabel001', staged_slot: { field_id: fieldIds.name, custom_label: longLabel, show_label: '1' }, }); @@ -2465,7 +2465,7 @@ suite.test('[hostile] Apply to a deleted view → 404 (not 500)', async () => { let status = null; let message = ''; try { - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'd01' }] }, }); @@ -2478,21 +2478,21 @@ suite.test('[hostile] Cross-view ifMatch → 412', async () => { // Mint view A, mutate it so its version counter bumps to ":1+", then mint B. // This guarantees A's etag is genuinely incompatible with B's fresh ":0". const viewA = await mintView('hostile-xview-A'); - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewA, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'bump_a' }] }, }); - const cfgA = await h.gv_get_view_config({ id: viewA }); + const cfgA = await h.gv_view_config_get({ id: viewA }); const etagA = `"${cfgA.version}"`; const viewB = await mintView('hostile-xview-B'); - const cfgB = await h.gv_get_view_config({ id: viewB }); + const cfgB = await h.gv_view_config_get({ id: viewB }); // Sanity: versions must differ — otherwise the test can't prove anything. TestAssert.isTrue(cfgA.version !== cfgB.version, `view versions must differ; got A=${cfgA.version} B=${cfgB.version}`); let status = null, message = ''; try { - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewB, ifMatch: etagA, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'xvm01' }] }, }); @@ -2503,15 +2503,15 @@ suite.test('[hostile] Cross-view ifMatch → 412', async () => { suite.test('[hostile] Same apply twice with same ifMatch → second 412', async () => { if (suite.skip) return; const viewId = await mintView('hostile-replay'); - const cfg = await h.gv_get_view_config({ id: viewId }); + const cfg = await h.gv_view_config_get({ id: viewId }); const etag = `"${cfg.version}"`; - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, ifMatch: etag, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'rep01' }] }, }); let status = null; try { - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, ifMatch: etag, mode: 'merge', fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'rep02' }] }, }); @@ -2556,11 +2556,11 @@ suite.test('[hostile] Self-ref: custom_content embedding [gravityview id=" // didn't exist before this pass.) // ============================================================ -suite.test('Consolidated schema: gv_get_view_field_schemas with no filter returns the bulk map', async () => { +suite.test('Consolidated schema: gv_view_field_schemas_get with no filter returns the bulk map', async () => { if (suite.skip) return; const viewId = await mintView('schema bulk mode'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - await h.gv_apply_view_config({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + await h.gv_view_config_apply({ id: viewId, fields: { 'directory_table-columns': [ { field_id: fieldIds.name, slot: 'sb_a' }, @@ -2568,17 +2568,17 @@ suite.test('Consolidated schema: gv_get_view_field_schemas with no filter return ] }, mode: 'merge', }); - const r = await h.gv_get_view_field_schemas({ id: viewId }); + const r = await h.gv_view_field_schemas_get({ id: viewId }); TestAssert.isTrue(typeof r.schemas === 'object', 'schemas object present'); const keys = Object.keys(r.schemas); TestAssert.isTrue(keys.length >= 2, `bulk returned at least the 2 placed slots (got ${keys.length})`); }); -suite.test('Consolidated schema: gv_get_view_field_schemas filtered to area+slot returns one-key map', async () => { +suite.test('Consolidated schema: gv_view_field_schemas_get filtered to area+slot returns one-key map', async () => { if (suite.skip) return; const viewId = await mintView('schema single mode'); - await h.gv_patch_view_template({ id: viewId, template_id: 'default_table' }); - await h.gv_apply_view_config({ + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + await h.gv_view_config_apply({ id: viewId, fields: { 'directory_table-columns': [ { field_id: fieldIds.name, slot: 'sb_solo' }, @@ -2586,7 +2586,7 @@ suite.test('Consolidated schema: gv_get_view_field_schemas filtered to area+slot ] }, mode: 'merge', }); - const r = await h.gv_get_view_field_schemas({ + const r = await h.gv_view_field_schemas_get({ id: viewId, area: 'directory_table-columns', slot: 'sb_solo', @@ -2596,28 +2596,35 @@ suite.test('Consolidated schema: gv_get_view_field_schemas filtered to area+slot TestAssert.equal(keys[0], 'directory_table-columns/sb_solo', 'returned key matches the requested area/slot'); }); -suite.test('Safety: NO permanent-delete ability exists — only set-view-status: trash is the soft-remove path', async () => { +suite.test('Safety: gv_view_delete defaults to soft delete (mode=trash, recoverable from trash)', async () => { if (suite.skip) return; - // Anti-test: delete-view was intentionally NOT shipped. AI agents - // shouldn't have a one-call permanent destruction path. Soft-delete - // via set-view-status: trash is the canonical path; recovery is via - // WP admin's "Restore from trash". This test pins that contract. + // gv_view_delete exists, but a default invocation (force omitted) + // soft-deletes the View — it transitions to trash status, + // recoverable from WP admin's "Restore from trash". This test pins + // the contract: an AI agent calling gv_view_delete without + // explicitly setting force=true gets a recoverable delete, not + // permanent destruction. TestAssert.isTrue( - typeof h.gv_delete_view === 'undefined', - 'gv_delete_view ability must NOT exist (permanent destruction is admin-only by design)', + typeof h.gv_view_delete === 'function', + 'gv_view_delete is loaded as a tool handler', ); + const viewId = await mintView('soft-delete via gv_view_delete'); + const r = await h.gv_view_delete({ id: viewId }); + TestAssert.equal(r.deleted, true, 'default invocation reports deleted=true'); + TestAssert.equal(r.mode, 'trash', 'default invocation soft-deletes (mode=trash)'); + TestAssert.equal(r.force, false, 'default does not force-permadelete'); }); suite.test('Safety: set-view-status: trash works as the soft-remove path (recoverable)', async () => { if (suite.skip) return; const viewId = await mintView('soft-trash via set-status'); - const r = await h.gv_set_view_status({ id: viewId, status: 'trash' }); + const r = await h.gv_view_status_set({ id: viewId, status: 'trash' }); TestAssert.equal(r.changed, true); TestAssert.equal(r.status, 'trash'); // Trashed views are still in the post table — recoverable. The // get_post_status check is the canonical "is this trashed" probe. // We assert via wp-cli rather than a REST round-trip because the - // trashed view's accessibility through gv_get_view_config is a + // trashed view's accessibility through gv_view_config_get is a // separate contract. }); @@ -2632,7 +2639,7 @@ suite.test('Safety: abilities-loader does NOT add a client-side destructive gate // destructive ability" gate was removed when DeleteView shipped — // there is no longer any "delete the whole View" path through the // ability registry, so the env-var ratchet served no remaining - // purpose. Status-level removal still flows through gv_set_view_status + // purpose. Status-level removal still flows through gv_view_status_set // and is gated server-side by the WP `delete_post` capability. const { loadAbilitiesAsTools } = await import('../abilities/loader.js'); const { handlers } = await loadAbilitiesAsTools(gvClient); @@ -2648,11 +2655,11 @@ suite.test('Safety: abilities-loader does NOT add a client-side destructive gate ); }); -suite.test('New ability: gv_duplicate_view clones form + template + fields', async () => { +suite.test('New ability: gv_view_duplicate clones form + template + fields', async () => { if (suite.skip) return; const sourceId = await mintView('duplicate source'); - await h.gv_patch_view_template({ id: sourceId, template_id: 'default_table' }); - await h.gv_apply_view_config({ + await h.gv_view_template_switch({ id: sourceId, template_id: 'default_table' }); + await h.gv_view_config_apply({ id: sourceId, fields: { 'directory_table-columns': [ { field_id: fieldIds.name, slot: 'dup_a', custom_label: 'Carry-over' }, @@ -2660,14 +2667,14 @@ suite.test('New ability: gv_duplicate_view clones form + template + fields', asy mode: 'merge', }); - const r = await h.gv_duplicate_view({ id: sourceId, title: 'Duplicated for stress test' }); + const r = await h.gv_view_duplicate({ id: sourceId, title: 'Duplicated for stress test' }); TestAssert.equal(r.duplicated, true); TestAssert.equal(r.source_id, sourceId); TestAssert.isTrue(r.view_id > 0 && r.view_id !== sourceId, 'fresh post id, distinct from source'); TestAssert.equal(r.title, 'Duplicated for stress test'); mintedViewIds.push(r.view_id); - const dup = await h.gv_get_view_config({ id: r.view_id }); + const dup = await h.gv_view_config_get({ id: r.view_id }); TestAssert.equal(dup.form_id, Number(formId), 'form binding cloned'); TestAssert.equal(dup.template_id, 'default_table', 'template cloned'); TestAssert.equal( @@ -2677,30 +2684,30 @@ suite.test('New ability: gv_duplicate_view clones form + template + fields', asy ); }); -suite.test('New ability: gv_set_view_status — publish → draft round-trip + idempotent re-set', async () => { +suite.test('New ability: gv_view_status_set — publish → draft round-trip + idempotent re-set', async () => { if (suite.skip) return; const viewId = await mintView('set-status round-trip'); - const pub = await h.gv_set_view_status({ id: viewId, status: 'publish' }); + const pub = await h.gv_view_status_set({ id: viewId, status: 'publish' }); TestAssert.equal(pub.status, 'publish'); TestAssert.equal(pub.previous_status, 'draft'); TestAssert.equal(pub.changed, true); - const noop = await h.gv_set_view_status({ id: viewId, status: 'publish' }); + const noop = await h.gv_view_status_set({ id: viewId, status: 'publish' }); TestAssert.equal(noop.changed, false, 'idempotent — same status returns changed: false'); - const draft = await h.gv_set_view_status({ id: viewId, status: 'draft' }); + const draft = await h.gv_view_status_set({ id: viewId, status: 'draft' }); TestAssert.equal(draft.status, 'draft'); TestAssert.equal(draft.previous_status, 'publish'); TestAssert.equal(draft.changed, true); }); -suite.test('New ability: gv_set_view_status rejects an invalid status enum value', async () => { +suite.test('New ability: gv_view_status_set rejects an invalid status enum value', async () => { if (suite.skip) return; const viewId = await mintView('set-status invalid'); let status = null; try { - await h.gv_set_view_status({ id: viewId, status: 'totally_made_up' }); + await h.gv_view_status_set({ id: viewId, status: 'totally_made_up' }); } catch (err) { status = err?.response?.status ?? null; } @@ -2711,10 +2718,10 @@ suite.test('New ability: gv_set_view_status rejects an invalid status enum value // Coverage for the post-Gemini-review enhancements // ==================================================================== -suite.test('New ability: gv_list_views enumerates with status / form_id / search filters', async () => { +suite.test('New ability: gv_views_list enumerates with status / form_id / search filters', async () => { if (suite.skip) return; const seedTitle = `[stress] list-views needle ${Date.now()}`; - const view = await h.gv_create_view({ + const view = await h.gv_view_create({ title: seedTitle, form_id: Number(formId), template_id: 'gravityview-layout-builder', @@ -2723,7 +2730,7 @@ suite.test('New ability: gv_list_views enumerates with status / form_id / search mintedViewIds.push(view.view_id); // Substring search picks up the freshly-created View. - const found = await h.gv_list_views({ search: 'list-views needle', per_page: 10 }); + const found = await h.gv_views_list({ search: 'list-views needle', per_page: 10 }); TestAssert.isTrue(Array.isArray(found.views), 'returns views array'); TestAssert.isTrue(found.total >= 1, 'total reflects matching count'); const match = found.views.find((v) => v.view_id === view.view_id); @@ -2732,28 +2739,28 @@ suite.test('New ability: gv_list_views enumerates with status / form_id / search TestAssert.equal(match.status, 'draft', 'status reflected'); // form_id filter narrows to that form (every result must match). - const byForm = await h.gv_list_views({ form_id: Number(formId), per_page: 5 }); + const byForm = await h.gv_views_list({ form_id: Number(formId), per_page: 5 }); for (const v of byForm.views) { TestAssert.equal(v.form_id, Number(formId), 'every row matches form_id filter'); } // Pagination metadata. - const paged = await h.gv_list_views({ per_page: 2, page: 1 }); + const paged = await h.gv_views_list({ per_page: 2, page: 1 }); TestAssert.equal(paged.per_page, 2, 'per_page echoed'); TestAssert.equal(paged.page, 1, 'page echoed'); TestAssert.isTrue(paged.total_pages >= 1, 'total_pages computed'); }); -suite.test('Projection: gv_get_view_config include narrows the response shape', async () => { +suite.test('Projection: gv_view_config_get include narrows the response shape', async () => { if (suite.skip) return; const viewId = await mintView('projection'); - const full = await h.gv_get_view_config({ id: viewId, compact: false }); + const full = await h.gv_view_config_get({ id: viewId, compact: false }); TestAssert.isTrue('template_id' in full, 'full has template_id'); TestAssert.isTrue('fields' in full, 'full has fields'); TestAssert.isTrue('widgets' in full, 'full has widgets'); - const slim = await h.gv_get_view_config({ + const slim = await h.gv_view_config_get({ id: viewId, include: ['template_settings', 'form_id'], compact: false, @@ -2766,7 +2773,7 @@ suite.test('Projection: gv_get_view_config include narrows the response shape', TestAssert.isTrue(!('template_id' in slim), 'unrequested template_id stripped'); }); -suite.test('Dry-run: gv_apply_view_config dry_run=true does NOT persist + flags response', async () => { +suite.test('Dry-run: gv_view_config_apply dry_run=true does NOT persist + flags response', async () => { if (suite.skip) return; const viewId = await mintView('dry-run apply'); @@ -2774,51 +2781,52 @@ suite.test('Dry-run: gv_apply_view_config dry_run=true does NOT persist + flags // so re-fetch + re-quote between writes (the test harness only // caches the version returned by the LAST call; the unit-test // happens to interleave reads + writes that defeat that). - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', template_settings: { page_size: 25 }, }); - const before = await h.gv_get_view_config({ id: viewId, include: ['template_settings'] }); + const before = await h.gv_view_config_get({ id: viewId, include: ['template_settings'] }); TestAssert.equal(before.template_settings.page_size, 25, 'baseline persisted'); - const dry = await h.gv_apply_view_config({ + const dry = await h.gv_view_config_apply({ id: viewId, mode: 'merge', template_settings: { page_size: 999 }, dry_run: true, }); TestAssert.equal(dry.dry_run, true, 'response flagged dry_run'); - TestAssert.equal(dry.would_apply, true, 'response flagged would_apply'); + // would_apply was dropped in Foundation FIX-22/66 (redundant with dry_run). + // The dry-run envelope is now { applied, dry_run, version, view_id }. - const after = await h.gv_get_view_config({ id: viewId, include: ['template_settings'] }); + const after = await h.gv_view_config_get({ id: viewId, include: ['template_settings'] }); TestAssert.equal(after.template_settings.page_size, 25, 'meta unchanged after dry-run'); }); -suite.test('Dry-run: gv_patch_view_field dry_run=true validates without persisting', async () => { +suite.test('Dry-run: gv_view_field_patch dry_run=true validates without persisting', async () => { if (suite.skip) return; const viewId = await mintView('dry-run patch-field'); - await h.gv_add_grid_row({ + await h.gv_grid_row_add({ id: viewId, surface: 'fields', row_uid: 'r1', type: '100', template_ids: ['default_table'], }); - const added = await h.gv_add_view_field({ + const added = await h.gv_view_field_add({ id: viewId, area: 'directory_table-columns', field_id: 'custom', label: 'Original', }); - await h.gv_patch_view_field({ + await h.gv_view_field_patch({ id: viewId, area: 'directory_table-columns', slot: added.slot, settings: { custom_label: 'Real Label' }, }); - const dryPatch = await h.gv_patch_view_field({ + const dryPatch = await h.gv_view_field_patch({ id: viewId, area: 'directory_table-columns', slot: added.slot, @@ -2827,15 +2835,15 @@ suite.test('Dry-run: gv_patch_view_field dry_run=true validates without persisti }); TestAssert.equal(dryPatch.dry_run, true); - const after = await h.gv_get_view_config({ id: viewId }); + const after = await h.gv_view_config_get({ id: viewId }); const stored = after.fields['directory_table-columns']?.[added.slot]?.custom_label; TestAssert.equal(stored, 'Real Label', 'meta still holds the real-write value, not the dry-run value'); }); -suite.test('Dry-run: gv_add_view_field dry_run=true returns shape but does NOT add a slot', async () => { +suite.test('Dry-run: gv_view_field_add dry_run=true returns shape but does NOT add a slot', async () => { if (suite.skip) return; const viewId = await mintView('dry-run add-field'); - await h.gv_add_grid_row({ + await h.gv_grid_row_add({ id: viewId, surface: 'fields', row_uid: 'r1', @@ -2844,10 +2852,10 @@ suite.test('Dry-run: gv_add_view_field dry_run=true returns shape but does NOT a }); const beforeCount = Object.keys( - (await h.gv_get_view_config({ id: viewId })).fields?.['directory_table-columns'] ?? {}, + (await h.gv_view_config_get({ id: viewId })).fields?.['directory_table-columns'] ?? {}, ).length; - const dry = await h.gv_add_view_field({ + const dry = await h.gv_view_field_add({ id: viewId, area: 'directory_table-columns', field_id: 'custom', @@ -2857,7 +2865,7 @@ suite.test('Dry-run: gv_add_view_field dry_run=true returns shape but does NOT a TestAssert.equal(dry.dry_run, true); const afterCount = Object.keys( - (await h.gv_get_view_config({ id: viewId })).fields?.['directory_table-columns'] ?? {}, + (await h.gv_view_config_get({ id: viewId })).fields?.['directory_table-columns'] ?? {}, ).length; TestAssert.equal(afterCount, beforeCount, 'slot count unchanged after dry-run add'); }); @@ -2884,29 +2892,33 @@ suite.test('Catalog: every gk-gravityview ability advertises a next_steps annota } }); -suite.test('Discovery bridge: list-layouts has_grid description points at list-grid-row-types', async () => { +suite.test('Discovery bridge: layouts-list has_grid description points at grid-row-types-list', async () => { if (suite.skip) return; const { data: catalog } = await gvClient.httpClient.request({ method: 'GET', baseURL: gvClient.baseUrl, url: '/wp-json/wp-abilities/v1/abilities', }); - const listLayouts = catalog.find((a) => a.name === 'gk-gravityview/list-layouts'); + // Foundation's verb-first → noun-first ability rename moved + // `list-layouts` → `layouts-list`, `list-grid-row-types` → + // `grid-row-types-list`, `list-view-areas` → `view-areas-get`. + // The has_grid description bridges to the new noun-first names. + const layoutsList = catalog.find((a) => a.name === 'gk-gravityview/layouts-list'); const hasGridDesc = - listLayouts?.output_schema?.properties?.layouts?.items?.properties?.has_grid?.description ?? ''; + layoutsList?.output_schema?.properties?.layouts?.items?.properties?.has_grid?.description ?? ''; TestAssert.isTrue( - hasGridDesc.includes('list-grid-row-types'), - 'has_grid description bridges to list-grid-row-types (the discovery step)', + hasGridDesc.includes('grid-row-types-list'), + 'has_grid description bridges to grid-row-types-list (the discovery step)', ); TestAssert.isTrue( - hasGridDesc.includes('list-view-areas'), - 'has_grid description bridges to list-view-areas for static layouts', + hasGridDesc.includes('view-areas-get'), + 'has_grid description bridges to view-areas-get for static layouts', ); }); suite.test('Field presets: default catalog is empty (filter-driven, core ships none)', async () => { if (suite.skip) return; - const r = await h.gv_list_field_presets(); + const r = await h.gv_field_presets_list(); TestAssert.equal(r.count, 0, 'no core-shipped presets'); TestAssert.isTrue(Array.isArray(r.presets), 'presets is an array'); TestAssert.equal(r.presets.length, 0); @@ -2917,7 +2929,7 @@ suite.test('Field presets: apply-field-preset rejects an unknown preset id with const viewId = await mintView('preset 404'); let status = null; try { - await h.gv_apply_field_preset({ + await h.gv_field_preset_apply({ id: viewId, preset_id: 'definitely-not-registered', area: 'directory_list-title', @@ -3088,7 +3100,7 @@ suite.test('MFV: apply-view-config writes joins via the cross-plugin filter', as const viewId = await mintView('mfv cross-plugin'); const joinedFormId = await mintSecondaryForm('crossplugin'); - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', joins: [ @@ -3097,7 +3109,7 @@ suite.test('MFV: apply-view-config writes joins via the cross-plugin filter', as ], }); - const cfg = await h.gv_get_view_config({ id: viewId, compact: false }); + const cfg = await h.gv_view_config_get({ id: viewId, compact: false }); TestAssert.isTrue(Array.isArray(cfg.joins), 'get-view-config exposes joins'); TestAssert.equal(cfg.joins.length, 2, 'apply-view-config persisted both joins'); @@ -3113,7 +3125,7 @@ suite.test('MFV: get-view-config include=[joins] projection narrows shape', asyn joins: [[Number(formId), '1', joinedFormId, '1']], }); - const slim = await h.gv_get_view_config({ + const slim = await h.gv_view_config_get({ id: viewId, include: ['joins'], compact: false, @@ -3136,14 +3148,14 @@ suite.test('MFV: list-views match_joined surfaces Views joining a form (not just }); // Search for Views connected to the JOINED form (not primary). - const matched = await h.gv_list_views({ form_id: joinedFormId, match_joined: true }); + const matched = await h.gv_views_list({ form_id: joinedFormId, match_joined: true }); TestAssert.isTrue( matched.views.some((v) => v.view_id === viewId), 'View surfaces under match_joined when its form is only joined', ); // Without match_joined, the joined-only View must NOT show up. - const unmatched = await h.gv_list_views({ form_id: joinedFormId }); + const unmatched = await h.gv_views_list({ form_id: joinedFormId }); TestAssert.isTrue( !unmatched.views.some((v) => v.view_id === viewId), 'plain form_id filter excludes joined-only Views', @@ -3161,7 +3173,7 @@ suite.test('MFV: list-available-fields includes joined_form_fields tagged with f joins: [[Number(formId), '1', joinedFormId, '1']], }); - const r = await h.gv_list_available_fields({ id: viewId, zone: 'directory' }); + const r = await h.gv_available_fields_get({ id: viewId, zone: 'directory' }); TestAssert.isTrue(Array.isArray(r.joined_form_fields), 'joined_form_fields is an array'); TestAssert.isTrue(r.joined_form_fields.length > 0, 'at least one joined-form field returned'); for (const f of r.joined_form_fields) { @@ -3209,7 +3221,7 @@ suite.test('MFV deep: field slots from primary AND joined forms coexist in one a }); // Layout Builder needs a row before fields can land. - await h.gv_add_grid_row({ + await h.gv_grid_row_add({ id: viewId, surface: 'fields', row_uid: 'mixed_row', @@ -3218,7 +3230,7 @@ suite.test('MFV deep: field slots from primary AND joined forms coexist in one a }); // Discover fields available from both forms. - const avail = await h.gv_list_available_fields({ id: viewId, zone: 'directory' }); + const avail = await h.gv_available_fields_get({ id: viewId, zone: 'directory' }); TestAssert.isTrue(Array.isArray(avail.form_fields), 'primary form_fields surfaced'); TestAssert.isTrue(Array.isArray(avail.joined_form_fields), 'joined_form_fields surfaced'); TestAssert.isTrue(avail.form_fields.length > 0, 'primary form has fields'); @@ -3238,7 +3250,7 @@ suite.test('MFV deep: field slots from primary AND joined forms coexist in one a TestAssert.isTrue(!!joinedFieldId, 'have a joined field id'); // Add a slot from the PRIMARY form into the View's grid area. - const primarySlot = await h.gv_add_view_field({ + const primarySlot = await h.gv_view_field_add({ id: viewId, area: 'directory_mixed_row-1', field_id: primaryFieldId, @@ -3248,7 +3260,7 @@ suite.test('MFV deep: field slots from primary AND joined forms coexist in one a TestAssert.isTrue(!!primarySlot.slot, 'primary slot created'); // Add a slot from the JOINED form into the SAME area. - const joinedSlot = await h.gv_add_view_field({ + const joinedSlot = await h.gv_view_field_add({ id: viewId, area: 'directory_mixed_row-1', field_id: joinedFieldId, @@ -3259,7 +3271,7 @@ suite.test('MFV deep: field slots from primary AND joined forms coexist in one a TestAssert.isTrue(joinedSlot.slot !== primarySlot.slot, 'unique slot UID despite same field_id'); // Verify both slots are persisted in the field tree under the same area. - const cfg = await h.gv_get_view_config({ id: viewId, compact: false }); + const cfg = await h.gv_view_config_get({ id: viewId, compact: false }); const area = cfg.fields?.['directory_mixed_row-1']; TestAssert.isTrue(typeof area === 'object' && area !== null, 'area exists in field tree'); TestAssert.isTrue(primarySlot.slot in area, 'primary slot in tree'); @@ -3295,7 +3307,7 @@ suite.test('MFV deep: 3-form join + fields from each form land in distinct areas TestAssert.isTrue(labels.every((l) => l.includes('/')), 'every join surfaces both form labels'); // available-fields now spans all three forms. - const avail = await h.gv_list_available_fields({ id: viewId }); + const avail = await h.gv_available_fields_get({ id: viewId }); const joinedFormIds = new Set(avail.joined_form_fields.map((f) => f.form_id)); TestAssert.isTrue(joinedFormIds.has(orderForm), 'orders form_id present in joined_form_fields'); TestAssert.isTrue(joinedFormIds.has(addressForm), 'addresses form_id present in joined_form_fields'); @@ -3303,7 +3315,7 @@ suite.test('MFV deep: 3-form join + fields from each form land in distinct areas // Place one field from each form into 3 different rows so the View // is genuinely cross-form authored. for (const rowUid of ['r_orders', 'r_addr']) { - await h.gv_add_grid_row({ + await h.gv_grid_row_add({ id: viewId, surface: 'fields', row_uid: rowUid, @@ -3312,14 +3324,14 @@ suite.test('MFV deep: 3-form join + fields from each form land in distinct areas }); } - await h.gv_add_view_field({ + await h.gv_view_field_add({ id: viewId, area: 'directory_r_orders-1', field_id: avail.joined_form_fields.find((f) => f.form_id === orderForm)?.id, label: 'Order Field', form_id: orderForm, }); - await h.gv_add_view_field({ + await h.gv_view_field_add({ id: viewId, area: 'directory_r_addr-1', field_id: avail.joined_form_fields.find((f) => f.form_id === addressForm)?.id, @@ -3327,7 +3339,7 @@ suite.test('MFV deep: 3-form join + fields from each form land in distinct areas form_id: addressForm, }); - const cfg = await h.gv_get_view_config({ id: viewId, compact: false }); + const cfg = await h.gv_view_config_get({ id: viewId, compact: false }); TestAssert.isTrue(Object.keys(cfg.fields?.['directory_r_orders-1'] ?? {}).length === 1, 'orders row has 1 slot'); TestAssert.isTrue(Object.keys(cfg.fields?.['directory_r_addr-1'] ?? {}).length === 1, 'address row has 1 slot'); @@ -3341,7 +3353,7 @@ suite.test('MFV deep: apply-view-config bulk — joins + fields from both forms const joinedFormId = await mintSecondaryForm('bulk'); // First materialise a row to host the slots. - await h.gv_add_grid_row({ + await h.gv_grid_row_add({ id: viewId, surface: 'fields', row_uid: 'bulk_row', @@ -3350,7 +3362,7 @@ suite.test('MFV deep: apply-view-config bulk — joins + fields from both forms }); // Bulk write everything in one shot: joins + fields tree spanning both forms. - await h.gv_apply_view_config({ + await h.gv_view_config_apply({ id: viewId, mode: 'merge', joins: [[Number(formId), '1', joinedFormId, '1']], @@ -3364,7 +3376,7 @@ suite.test('MFV deep: apply-view-config bulk — joins + fields from both forms }, }); - const cfg = await h.gv_get_view_config({ id: viewId, compact: false }); + const cfg = await h.gv_view_config_get({ id: viewId, compact: false }); TestAssert.isTrue(Array.isArray(cfg.joins) && cfg.joins.length === 1, 'bulk wrote joins'); const slots = cfg.fields?.['directory_bulk_row-1'] ?? {}; TestAssert.equal(Object.keys(slots).length, 4, 'four slots in bulk_row-1'); @@ -3384,7 +3396,7 @@ suite.test('MFV deep: dry_run on mixed-form bulk apply does NOT persist any slot const viewId = await mintView('mfv dry mixed'); const joinedFormId = await mintSecondaryForm('dry-mixed'); - await h.gv_add_grid_row({ + await h.gv_grid_row_add({ id: viewId, surface: 'fields', row_uid: 'dry_row', @@ -3392,7 +3404,7 @@ suite.test('MFV deep: dry_run on mixed-form bulk apply does NOT persist any slot template_ids: ['gravityview-layout-builder'], }); - const dry = await h.gv_apply_view_config({ + const dry = await h.gv_view_config_apply({ id: viewId, mode: 'merge', dry_run: true, @@ -3406,7 +3418,7 @@ suite.test('MFV deep: dry_run on mixed-form bulk apply does NOT persist any slot }); TestAssert.equal(dry.dry_run, true); - const cfg = await h.gv_get_view_config({ id: viewId, compact: false }); + const cfg = await h.gv_view_config_get({ id: viewId, compact: false }); TestAssert.isTrue(!cfg.joins || cfg.joins.length === 0, 'no joins persisted on dry-run'); const slots = cfg.fields?.['directory_dry_row-1'] ?? {}; TestAssert.equal(Object.keys(slots).length, 0, 'no slots persisted on dry-run'); @@ -3446,7 +3458,7 @@ suite.test('MFV deep: apply-joins clears + replaces, list-joins reflects each st TestAssert.equal(list.count, 0); // get-view-config reflects the cleared state. - const cfg = await h.gv_get_view_config({ id: viewId, include: ['joins'], compact: false }); + const cfg = await h.gv_view_config_get({ id: viewId, include: ['joins'], compact: false }); TestAssert.isTrue(!cfg.joins || cfg.joins.length === 0, 'cleared joins reflected in get-view-config'); await gfClient.deleteForm(f1).catch(() => {}); From 2da4fcc4224d0f8be4b677d881c0f4d9254bc116 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 12 Jun 2026 01:15:28 -0400 Subject: [PATCH 11/36] test(abilities): stress harness for Foundation 3.0.0 catalog contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/stress-abilities.mjs: synthetic 1,205-item paginated catalog exercising the products-filter naming model (declared gv_* prefixes + full-product-slug fallback names), collision/reserved-name guards, disabled/unnamed/foreign filtering, all schema normalization shapes, GET/POST/DELETE execution wire formats, the stamped meta.mcp_tool_name WP-core fallback path, and the empty-catalog self-heal throw. 19 checks; ~1ms per 1,205-item load, >1M handler calls/s. - loader.js: update comments — tool prefixes now come from each product's required mcp_prefix on gk/foundation/abilities/products (full-slug fallback), and mcp_tool_name is stamped into ability meta on both catalogs. No code changes needed: the loader was already shape-compatible. --- scripts/stress-abilities.mjs | 322 +++++++++++++++++++++++++++++++++++ src/abilities/loader.js | 10 +- 2 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 scripts/stress-abilities.mjs diff --git a/scripts/stress-abilities.mjs b/scripts/stress-abilities.mjs new file mode 100644 index 0000000..3cf629b --- /dev/null +++ b/scripts/stress-abilities.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** + * Stress + compatibility test for the abilities loader against the + * Foundation 3.0.0 catalog contract (GravityKit/Foundation#158): + * + * - products-filter naming: short declared prefixes (gv_*) AND + * full-product-slug fallback names in the same catalog + * - mcp_tool_name stamped into ability meta (WP core fallback path) + * - paginated Foundation catalog (page/per_page + X-WP-TotalPages) + * - collision guard: duplicates and reserved built-in names + * - schema normalization: object / array-properties / descriptor-array / + * null input_schema shapes + * - execution wire shapes: GET bracketed params, POST {input}, DELETE + * + * Pure synthetic — no live site needed. Exits non-zero on any failure. + * + * Usage: node scripts/stress-abilities.mjs + */ + +import assert from 'node:assert/strict'; +import { performance } from 'node:perf_hooks'; +import { + loadAbilitiesAsTools, + normalizeInputSchema, + methodForAbility, + FOUNDATION_CATALOG_ROUTE, + CORE_ABILITIES_ROUTE, +} from '../src/abilities/loader.js'; + +// ---------------------------------------------------------------- fixtures + +const SCHEMA_VARIANTS = [ + // Proper JSON Schema object. + (i) => ({ + type: 'object', + properties: { id: { type: 'integer' }, [`field_${i}`]: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }), + // PHP-serialised empty assoc array: properties as []. + () => ({ type: 'object', properties: [] }), + // Top-level array of parameter descriptors. + (i) => ([ + { name: 'view_id', type: 'integer', required: true }, + { slug: `arg_${i}`, type: 'string' }, + 'garbage-entry', + ]), + // Missing entirely. + () => null, +]; + +const ANNOTATION_VARIANTS = [ + { readonly: true }, + { destructive: true, idempotent: true }, + { destructive: true }, + {}, +]; + +/** One Foundation catalog item (Manager::to_rest_item() shape). */ +function foundationItem(i, overrides = {}) { + const product = i % 2 === 0 ? 'gravityview' : 'gravityboard'; // gv_ declared vs full-slug fallback + const prefix = i % 2 === 0 ? 'gv' : 'gravityboard'; + return { + name: `gk-${product}/op-${i}`, + label: `Op ${i}`, + description: `Synthetic ability ${i}.`, + category: `gk-${product}-stress`, + input_schema: SCHEMA_VARIANTS[i % SCHEMA_VARIANTS.length](i), + annotations: ANNOTATION_VARIANTS[i % ANNOTATION_VARIANTS.length], + enabled: true, + gk_product: product, + gk_scope: 'stress', + mcp_tool_name: `${prefix}_op_${i}`, + ...overrides, + }; +} + +/** One WP core catalog ability (meta-shaped, post-Foundation-stamping). */ +function coreAbility(i, overrides = {}) { + return { + name: `gk-gravityview/core-op-${i}`, + label: `Core op ${i}`, + description: `Core synthetic ${i}.`, + input_schema: SCHEMA_VARIANTS[i % SCHEMA_VARIANTS.length](i), + meta: { + gk_registered_by: 'gravitykit', + gk_product: 'gravityview', + mcp_tool_name: `gv_core_op_${i}`, + annotations: ANNOTATION_VARIANTS[i % ANNOTATION_VARIANTS.length], + }, + ...overrides, + }; +} + +function buildFoundationCatalog() { + const items = []; + for (let i = 0; i < 1140; i++) items.push(foundationItem(i)); + + // 20 disabled (defensive skip — the live catalog omits these by default). + for (let i = 0; i < 20; i++) items.push(foundationItem(5000 + i, { enabled: false })); + + // 20 without mcp_tool_name (server owns naming — skipped with warning). + for (let i = 0; i < 20; i++) items.push(foundationItem(6000 + i, { mcp_tool_name: '' })); + + // 10 names outside the gk- contract. + for (let i = 0; i < 10; i++) items.push(foundationItem(7000 + i, { name: `acme/op-${i}` })); + + // 10 duplicate tool names (collide with items 0..9 — first wins). + for (let i = 0; i < 10; i++) { + items.push(foundationItem(8000 + i, { mcp_tool_name: foundationItem(i).mcp_tool_name })); + } + + // 5 colliding with a reserved built-in name. + for (let i = 0; i < 5; i++) items.push(foundationItem(9000 + i, { mcp_tool_name: 'gf_list_forms' })); + + return items; +} + +// ------------------------------------------------------------- mock client + +function paginate(items, perPage, page) { + const totalPages = Math.max(1, Math.ceil(items.length / perPage)); + return { + data: items.slice((page - 1) * perPage, page * perPage), + headers: { 'x-wp-totalpages': String(totalPages) }, + }; +} + +/** + * @param {object} scenario + * foundation: items array | 'throw' + * core: abilities array | 'throw' + */ +function makeClient(scenario) { + const log = { foundationRequests: 0, coreRequests: 0, runs: [] }; + return { + log, + baseUrl: 'https://stress.test', + httpClient: { + async request(config) { + if (config.url === FOUNDATION_CATALOG_ROUTE) { + log.foundationRequests++; + if (scenario.foundation === 'throw') throw new Error('403 synthetic'); + return paginate(scenario.foundation, config.params.per_page, config.params.page); + } + if (config.url === CORE_ABILITIES_ROUTE) { + log.coreRequests++; + if (scenario.core === 'throw') throw new Error('404 synthetic'); + return { data: scenario.core, headers: {} }; + } + if (config.url.includes('/run')) { + log.runs.push(config); + return { data: { ok: true } }; + } + throw new Error(`Unexpected request: ${config.url}`); + }, + }, + }; +} + +// ----------------------------------------------------------------- helpers + +const results = []; +function check(label, fn) { + try { + fn(); + results.push(['PASS', label]); + } catch (err) { + results.push(['FAIL', `${label} — ${err.message}`]); + } +} + +function isPlainObject(v) { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +// ---------------------------------------------------------------- scenarios + +const catalog = buildFoundationCatalog(); +const reservedNames = new Set(['gf_list_forms', 'gf_get_form']); + +// A. Foundation path: filtering, collisions, pagination, schema validity. +{ + const client = makeClient({ foundation: catalog, core: [] }); + const tools = await loadAbilitiesAsTools(client, { reservedNames }); + + check('A1: source is foundation-catalog', () => assert.equal(tools.source, 'foundation-catalog')); + check('A2: exactly the 1,140 valid abilities become tools', () => assert.equal(tools.count, 1140)); + check('A3: handlers map matches definitions', () => + assert.equal(Object.keys(tools.handlers).length, tools.definitions.length)); + check('A4: pagination fetched all 13 pages', () => assert.equal(client.log.foundationRequests, 13)); + check('A5: reserved gf_list_forms never shadowed', () => + assert.equal(tools.definitions.filter((d) => d.name === 'gf_list_forms').length, 0)); + check('A6: duplicate tool names resolved first-wins (no doubles)', () => { + const names = tools.definitions.map((d) => d.name); + assert.equal(new Set(names).size, names.length); + }); + check('A7: full-slug fallback names (gravityboard_*) coexist with gv_*', () => { + assert.ok(tools.definitions.some((d) => d.name.startsWith('gravityboard_'))); + assert.ok(tools.definitions.some((d) => d.name.startsWith('gv_'))); + }); + check('A8: every inputSchema is MCP-valid (object type + plain-object properties)', () => { + for (const d of tools.definitions) { + assert.equal(d.inputSchema.type, 'object', d.name); + assert.ok(isPlainObject(d.inputSchema.properties), `${d.name} properties`); + } + }); + check('A9: descriptor-array schemas gained derived required list', () => { + const sample = tools.definitions.find((d) => d.inputSchema.properties.view_id); + assert.ok(sample, 'no descriptor-array tool found'); + assert.deepEqual(sample.inputSchema.required, ['view_id']); + }); + + // B. Execution wire shapes through real handlers. + const byName = Object.fromEntries(tools.definitions.map((d) => [d.name, d])); + const readonlyName = catalog.find((c) => c.annotations.readonly && byName[c.mcp_tool_name])?.mcp_tool_name; + const deleteName = catalog.find((c) => c.annotations.destructive && c.annotations.idempotent && byName[c.mcp_tool_name])?.mcp_tool_name; + const postName = catalog.find((c) => !c.annotations.readonly && !c.annotations.idempotent && byName[c.mcp_tool_name])?.mcp_tool_name; + + await tools.handlers[readonlyName]({ view: { id: 7, fields: ['a', 'b'] } }); + await tools.handlers[postName]({ title: 'Stress' }); + await tools.handlers[deleteName]({ id: 9 }); + + const [getRun, postRun, deleteRun] = client.log.runs; + check('B1: readonly handler issues GET with bracketed nested params', () => { + assert.equal(getRun.method, 'GET'); + assert.equal(getRun.params['input[view][id]'], 7); + assert.equal(getRun.params['input[view][fields][0]'], 'a'); + }); + check('B2: default handler issues POST with {input} body', () => { + assert.equal(postRun.method, 'POST'); + assert.deepEqual(postRun.data, { input: { title: 'Stress' } }); + }); + check('B3: destructive+idempotent handler issues DELETE', () => + assert.equal(deleteRun.method, 'DELETE')); + + // Throughput: sequential + concurrent handler execution. + const seqStart = performance.now(); + for (let i = 0; i < 5000; i++) await tools.handlers[postName]({ i }); + const seqMs = performance.now() - seqStart; + + const conStart = performance.now(); + await Promise.all(Array.from({ length: 2000 }, (_, i) => tools.handlers[readonlyName]({ i }))); + const conMs = performance.now() - conStart; + + results.push(['INFO', `B4: 5,000 sequential handler calls in ${seqMs.toFixed(0)}ms (${Math.round(5000 / (seqMs / 1000)).toLocaleString()}/s)`]); + results.push(['INFO', `B5: 2,000 concurrent handler calls in ${conMs.toFixed(0)}ms`]); +} + +// C. Repeated full loads (timing + stability). +{ + const ITERATIONS = 25; + const times = []; + const rssBefore = process.memoryUsage().rss; + for (let i = 0; i < ITERATIONS; i++) { + const client = makeClient({ foundation: catalog, core: [] }); + const start = performance.now(); + const tools = await loadAbilitiesAsTools(client, { reservedNames }); + times.push(performance.now() - start); + assert.equal(tools.count, 1140); + } + const rssAfter = process.memoryUsage().rss; + const avg = times.reduce((a, b) => a + b, 0) / times.length; + results.push(['INFO', + `C1: ${ITERATIONS} full loads of 1,205-item catalog — avg ${avg.toFixed(1)}ms, ` + + `min ${Math.min(...times).toFixed(1)}ms, max ${Math.max(...times).toFixed(1)}ms, ` + + `RSS +${((rssAfter - rssBefore) / 1024 / 1024).toFixed(1)}MB`]); + check('C2: load stays under 250ms avg for 1,205 items', () => assert.ok(avg < 250, `${avg.toFixed(1)}ms`)); +} + +// D. WP core fallback (Foundation 403) reads stamped meta.mcp_tool_name. +{ + const core = []; + for (let i = 0; i < 600; i++) core.push(coreAbility(i)); + for (let i = 0; i < 50; i++) core.push(coreAbility(9000 + i, { meta: { some_other_plugin: true } })); + for (let i = 0; i < 10; i++) { + core.push(coreAbility(9500 + i, { meta: { gk_registered_by: 'gravitykit' } })); // no mcp_tool_name + } + + const client = makeClient({ foundation: 'throw', core }); + const tools = await loadAbilitiesAsTools(client, { reservedNames }); + + check('D1: falls back to wp-core source', () => assert.equal(tools.source, 'wp-core')); + check('D2: foreign + unnamed abilities filtered; 600 stamped survive', () => assert.equal(tools.count, 600)); + check('D3: core-path names come from stamped meta.mcp_tool_name', () => + assert.ok(tools.definitions.every((d) => d.name.startsWith('gv_core_op_')))); +} + +// E. Empty world: both catalogs unusable → throws (self-heal contract). +{ + const client = makeClient({ foundation: [], core: [] }); + let threw = false; + try { + await loadAbilitiesAsTools(client, { reservedNames }); + } catch { + threw = true; + } + check('E1: empty Foundation catalog + empty core throws for self-heal retry', () => assert.ok(threw)); +} + +// F. Unit edges already covered elsewhere, asserted here as a canary. +check('F1: methodForAbility contract', () => { + assert.equal(methodForAbility({ readonly: true }), 'GET'); + assert.equal(methodForAbility({ destructive: true, idempotent: true }), 'DELETE'); + assert.equal(methodForAbility({ destructive: true }), 'POST'); + assert.equal(methodForAbility(), 'POST'); +}); +check('F2: normalizeInputSchema never returns array properties', () => { + for (const variant of [null, [], [{ name: 'x' }], { type: 'object', properties: [] }, 'junk', 42]) { + const out = normalizeInputSchema(variant); + assert.equal(out.type, 'object'); + assert.ok(isPlainObject(out.properties)); + } +}); + +// ------------------------------------------------------------------ report + +const failed = results.filter(([s]) => s === 'FAIL'); +console.log('\n=== abilities loader stress test ==='); +for (const [status, label] of results) console.log(`${status.padEnd(5)} ${label}`); +console.log(`\n${results.filter(([s]) => s === 'PASS').length} passed, ${failed.length} failed`); +process.exit(failed.length ? 1 : 0); diff --git a/src/abilities/loader.js b/src/abilities/loader.js index e8f5217..b861cfa 100644 --- a/src/abilities/loader.js +++ b/src/abilities/loader.js @@ -7,8 +7,10 @@ * 1. Foundation catalog — `/wp-json/gravitykit/v1/abilities`. * The canonical contract: server-side GravityKit filtering * (`gk_registered_by === 'gravitykit'`), server-owned tool naming - * (`mcp_tool_name`, derived from Foundation's per-product - * MCP_TOOL_PREFIXES map), and disabled abilities already omitted. + * (`mcp_tool_name`, from each product's required `mcp_prefix` declared + * on Foundation's `gk/foundation/abilities/products` filter, falling + * back to the full product slug), and disabled abilities already + * omitted. * Any GravityKit product that registers abilities through Foundation * appears here automatically — no client-side allow-list. * 2. WP core catalog — `/wp-json/wp-abilities/v1/abilities`. @@ -22,7 +24,9 @@ * gv_* call (self-healing). * * Tool naming is owned by the SERVER on both paths: Foundation's - * `mcp_tool_name` (Manager::get_mcp_tool_name() + MCP_TOOL_PREFIXES). + * `mcp_tool_name` (Manager::get_mcp_tool_name() — declared `mcp_prefix` + * or the full-product-slug fallback), stamped into ability meta so the + * WP core catalog carries it too. * The client never invents names — abilities arriving without * `mcp_tool_name` are skipped with a warning, so a naming gap is * visible instead of silently diverging between connections. From d19c5af678ccbb8fa894eafc087713e29a1dc9ec Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 12 Jun 2026 13:05:58 -0400 Subject: [PATCH 12/36] feat: apply Gravity Forms feature-abilities-api branch findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checked against gravityforms/gravityforms#3716 (25 abilities + bundled MCP Adapter, registered on wp_abilities_api_init with show_in_rest:true under the gravityforms/* namespace). - Coexistence test: GF's abilities DO appear in the WP core catalog our fallback path reads; pin that the gk_registered_by metadata filter excludes them (core gravityforms/* and the two-slash add-on convention gravityforms/{addon}/{action}). - Layout grid normalization in FieldManager (ported from GF_Abilities_Handler_Forms::normalize_layout_group_ids): friendly layoutGroupId names hash to the editor's 8-char hex format — stable per form so sequential gf_add_field calls can share a row — and layoutGridColumnSpan clamps to the 1-12 grid. - Doc fix: loader header now states the destructive+idempotent DELETE rule (the methodForAbility code/tests were already fixed). Note: field-manager.test.js has 11 pre-existing failures under direct node --test (stale mock response shapes); not run by the custom runner. --- src/abilities/loader.js | 2 +- src/field-operations/field-manager.js | 41 +++++++++++++++++++++++++++ src/tests/abilities-loader.test.js | 35 +++++++++++++++++++++++ src/tests/field-manager.test.js | 36 ++++++++++++++++++++++- 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/abilities/loader.js b/src/abilities/loader.js index b861cfa..e409088 100644 --- a/src/abilities/loader.js +++ b/src/abilities/loader.js @@ -33,7 +33,7 @@ * * Handlers execute abilities through `/wp-abilities/v1/abilities/{name}/run` * with the HTTP method derived from the ability's annotations - * (`readonly` → GET, `destructive` → DELETE, otherwise POST). + * (`readonly` → GET, `destructive`+`idempotent` → DELETE, otherwise POST). */ import logger from '../utils/logger.js'; diff --git a/src/field-operations/field-manager.js b/src/field-operations/field-manager.js index 707bb15..2d68248 100644 --- a/src/field-operations/field-manager.js +++ b/src/field-operations/field-manager.js @@ -3,6 +3,8 @@ * Handles field CRUD operations within REST API v2 constraints */ +import { createHash } from 'crypto'; + export class FieldManager { constructor(apiClient, fieldRegistry, validator) { this.api = apiClient; @@ -40,6 +42,9 @@ export class FieldManager { if (fieldDef.storage?.type === 'compound') { field.inputs = this.generateSubInputs(field, fieldDef); } + + // 5b. Normalize layout grid properties (layoutGroupId, layoutGridColumnSpan) + this.normalizeLayoutProperties(field, formId); // 6. Calculate insertion position (page-aware) const insertIndex = this.positionEngine?.calculatePosition( @@ -91,6 +96,7 @@ export class FieldManager { ...updates, id: originalField.id // Preserve ID }; + this.normalizeLayoutProperties(form.fields[fieldIndex], formId); // Replace form via direct PUT (no re-fetch — we already have the full state) const result = await this.api.replaceForm(formId, form); @@ -200,6 +206,41 @@ export class FieldManager { }; } + /** + * Normalize layout grid properties to the editor's storage format. + * + * Mirrors the server-side normalization Gravity Forms ships in its + * abilities API (GF_Abilities_Handler_Forms::normalize_layout_group_ids): + * the editor stores layoutGroupId as an 8-char lowercase hex string, but + * agents naturally write friendly names like "row1" or "name-row". + * Friendly names hash to a stable 8-char hex per form, so the same name + * passed to later calls lands the field in the same row (GF salts per + * request because it normalizes a whole form at once; we normalize one + * field per call, so determinism is what makes row-sharing work). + * + * layoutGridColumnSpan is clamped to the editor's 1-12 grid; non-numeric + * values are dropped so the editor assigns its own span. + * + * Mutates and returns the field. + */ + normalizeLayoutProperties(field, formId) { + if (typeof field.layoutGridColumnSpan !== 'undefined') { + const span = parseInt(field.layoutGridColumnSpan, 10); + if (Number.isFinite(span)) { + field.layoutGridColumnSpan = Math.min(12, Math.max(1, span)); + } else { + delete field.layoutGridColumnSpan; + } + } + + const groupId = field.layoutGroupId; + if (typeof groupId === 'string' && groupId !== '' && !/^[0-9a-f]{8}$/.test(groupId)) { + field.layoutGroupId = createHash('md5').update(`${formId}:${groupId}`).digest('hex').slice(0, 8); + } + + return field; + } + /** * Generate compound sub-inputs (address.1, name.3, etc.) */ diff --git a/src/tests/abilities-loader.test.js b/src/tests/abilities-loader.test.js index 8113f96..f1e5f0e 100644 --- a/src/tests/abilities-loader.test.js +++ b/src/tests/abilities-loader.test.js @@ -381,6 +381,41 @@ suite.test('catalog path: tool-name collision — first wins, later skipped, nev TestAssert.equal(run.url, '/wp-json/wp-abilities/v1/abilities/gk-gravityview/views-list/run', 'handler must stay bound to the first claimant'); }); +suite.test('coexistence: Gravity Forms own abilities (feature-abilities-api) are never surfaced', async () => { + // Exact shape GF's branch registers (GF_Abilities_Registry::definition): + // gravityforms/* namespace, meta.mcp + annotations + show_in_rest:true, + // and NO gk_registered_by stamp. show_in_rest means these DO appear in + // the WP core catalog our fallback reads — the metadata filter is the + // only thing keeping them out. + const catalog = [ + ...syntheticCatalog(), + { + name: 'gravityforms/forms-list', + label: 'List Forms', + description: 'Lists Gravity Forms forms.', + input_schema: { type: 'object', properties: {} }, + meta: { + mcp: { public: true }, + annotations: { readonly: true, destructive: false, idempotent: true }, + show_in_rest: true, + }, + }, + { + // GF add-on convention uses a second slash. + name: 'gravityforms/myaddon/my-action', + description: 'Add-on ability.', + input_schema: { type: 'object', properties: {} }, + meta: { mcp: { public: true }, annotations: {}, show_in_rest: true }, + }, + ]; + const { definitions, source } = await loadAbilitiesAsTools(buildStubGvClient(catalog)); + TestAssert.equal(source, 'wp-core'); + const names = definitions.map((d) => d.name); + TestAssert.isTrue(!names.some((n) => n.includes('forms_list')), 'GF core abilities must not become tools'); + TestAssert.isTrue(!names.some((n) => n.includes('my_action')), 'GF add-on abilities must not become tools'); + TestAssert.equal(definitions.length, 3, 'only the gk_registered_by-stamped abilities surface'); +}); + suite.test('reserved names: catalog tools can never shadow the built-in gf_* contract', async () => { const colliding = [ { diff --git a/src/tests/field-manager.test.js b/src/tests/field-manager.test.js index b65db72..6e9904f 100644 --- a/src/tests/field-manager.test.js +++ b/src/tests/field-manager.test.js @@ -337,4 +337,38 @@ test('FieldManager - deleteField', async (t) => { assert.strictEqual(cleanupCalled, true); assert.ok(result.actions_taken.includes('Dependencies cleaned up')); }); -}); \ No newline at end of file +}); +test('FieldManager - normalizeLayoutProperties', async (t) => { + const manager = new FieldManager(createMockApiClient(), createMockRegistry(), createMockValidator()); + + await t.test('valid 8-char hex layoutGroupId passes through unchanged', () => { + const field = { layoutGroupId: 'a1b2c3d4' }; + manager.normalizeLayoutProperties(field, 7); + assert.strictEqual(field.layoutGroupId, 'a1b2c3d4'); + }); + + await t.test('friendly layoutGroupId hashes to stable 8-char hex per form', () => { + const first = manager.normalizeLayoutProperties({ layoutGroupId: 'name-row' }, 7); + const second = manager.normalizeLayoutProperties({ layoutGroupId: 'name-row' }, 7); + assert.match(first.layoutGroupId, /^[0-9a-f]{8}$/); + assert.strictEqual(first.layoutGroupId, second.layoutGroupId, 'same name + form must share a row'); + const otherForm = manager.normalizeLayoutProperties({ layoutGroupId: 'name-row' }, 8); + assert.notStrictEqual(first.layoutGroupId, otherForm.layoutGroupId, 'different forms must not collide'); + }); + + await t.test('layoutGridColumnSpan clamps to the 1-12 editor grid', () => { + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGridColumnSpan: 20 }, 1).layoutGridColumnSpan, 12); + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGridColumnSpan: 0 }, 1).layoutGridColumnSpan, 1); + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGridColumnSpan: '6' }, 1).layoutGridColumnSpan, 6); + }); + + await t.test('non-numeric layoutGridColumnSpan is dropped for the editor to assign', () => { + const field = manager.normalizeLayoutProperties({ layoutGridColumnSpan: 'wide' }, 1); + assert.strictEqual('layoutGridColumnSpan' in field, false); + }); + + await t.test('empty and missing layoutGroupId are left alone', () => { + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGroupId: '' }, 1).layoutGroupId, ''); + assert.strictEqual('layoutGroupId' in manager.normalizeLayoutProperties({}, 1), false); + }); +}); From 7dec18eb70f6e84fb6dc3b276b1f4dd8bf4e9ae2 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 12 Jun 2026 13:29:25 -0400 Subject: [PATCH 13/36] test: fix stale FieldManager mocks, wire node:test files into test:all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createMockApiClient predated two client changes: getForm() resolving { form } and the 1.4.1 replaceForm() direct-PUT path. All 11 failures were mock drift, not product bugs — field-manager.test.js now 31/31. New test:field-ops script runs the four node:test-based files (field-manager, field-registry, field-dependencies, field-positioner), which were invisible to both the custom runner and test:all. Chained into test:all so they can't silently rot again. --- package.json | 3 ++- src/tests/field-manager.test.js | 24 ++++++++++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 1fb69c5..dbcdb0b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "setup-test-data": "node scripts/setup-test-data.js", "test": "node src/tests/integration.test.js", "test:unit": "node src/tests/run.js", + "test:field-ops": "node --test src/tests/field-manager.test.js src/tests/field-registry.test.js src/tests/field-dependencies.test.js src/tests/field-positioner.test.js", "test:auth": "node src/tests/authentication.test.js", "test:forms": "node src/tests/forms.test.js", "test:entries": "node src/tests/entries.test.js", @@ -25,7 +26,7 @@ "test:tools": "node src/tests/server-tools.test.js", "test:compact": "node src/tests/compact.test.js", "test:views": "node src/tests/views.test.js", - "test:all": "npm run test:unit && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm run test:views && npm test", + "test:all": "npm run test:unit && npm run test:field-ops && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm run test:views && npm test", "test:coverage": "echo 'Running all tests with coverage analysis' && npm run test:all" }, "keywords": [ diff --git a/src/tests/field-manager.test.js b/src/tests/field-manager.test.js index 6e9904f..1447446 100644 --- a/src/tests/field-manager.test.js +++ b/src/tests/field-manager.test.js @@ -7,18 +7,22 @@ import test from 'node:test'; import assert from 'node:assert'; import { FieldManager } from '../field-operations/field-manager.js'; -// Mock dependencies +// Mock dependencies. Mirrors the GravityFormsClient contract FieldManager +// actually consumes: getForm() resolves { form } and replaceForm() does a +// direct PUT resolving { form } (see field-manager.js). const createMockApiClient = () => ({ - getForm: async (formId) => ({ - id: formId, - title: 'Test Form', - fields: [ - { id: 1, type: 'text', label: 'Name' }, - { id: 2, type: 'email', label: 'Email' }, - { id: 3, type: 'textarea', label: 'Message' } - ] + getForm: async () => ({ + form: { + id: 1, + title: 'Test Form', + fields: [ + { id: 1, type: 'text', label: 'Name' }, + { id: 2, type: 'email', label: 'Email' }, + { id: 3, type: 'textarea', label: 'Message' } + ] + } }), - updateForm: async (form) => ({ form }) + replaceForm: async (formId, form) => ({ form }) }); const createMockRegistry = () => ({ From 88bff318bb3b39ea9a10a24895c4ccd73bb35361 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 12 Jun 2026 14:03:59 -0400 Subject: [PATCH 14/36] fix(auth): OAuth 1.0a signatures for array/nested query params + empty-feeds normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the live integration suite against a minted GF 2.10.3 site (Siteminter, http://localhost — the OAuth fallback path): - OAuth signatures stringified arrays ({ include: [3] } signed as include=3) while axios sent include[]=3 on the wire — every OAuth GET with array params failed with 'invalid signature'. Affects released 2.1.1 on any non-HTTPS connection. Fix: shared flattenParams() turns params into PHP bracket-index pairs (include[0]=3, paging[page_size]=2) used by BOTH the signature base and a matching axios paramsSerializer, with strict RFC 3986 encoding (rawurlencode parity: !'()* escaped) per RFC 5849 §3.4.1.3.2 (encode, then sort by encoded name/value). - listFeeds: GF returns a serialized WP_Error ({errors:{not_found:[...]}}) with HTTP 200 when a site has no feeds — normalize to feeds: [] so callers always get an array. Verified: full live integration suite 24/24 against GF 2.10.3 over OAuth; unit 266, auth 22, feeds 25, field-ops 131 — all green. --- src/config/auth.js | 73 +++++++++++++++++++++++++++++-------- src/gravity-forms-client.js | 22 ++++++++++- 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/src/config/auth.js b/src/config/auth.js index e40f958..5294e0d 100644 --- a/src/config/auth.js +++ b/src/config/auth.js @@ -69,6 +69,44 @@ export class BasicAuthHandler { } } +/** + * Strict RFC 3986 percent-encoding (OAuth 1.0a requires it; WordPress + * verifies signatures with PHP's rawurlencode, which also encodes + * !'()* — encodeURIComponent alone leaves them bare and the + * signatures diverge). + */ +export function rfc3986Encode(value) { + return encodeURIComponent(value).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()); +} + +/** + * Flatten nested params into the bracket-index pairs PHP parses them + * back into: { include: [3, 5] } → [['include[0]','3'], ['include[1]','5']], + * { paging: { page_size: 2 } } → [['paging[page_size]','2']]. + * + * Both the OAuth signature base AND the wire serializer use this, so + * what we sign is byte-for-byte what Gravity Forms' server-side + * signature check reconstructs from $_GET. (The released 2.1.1 bug: + * the signature stringified arrays as "3" while axios sent include[]=3 + * — every OAuth GET with array params failed with invalid signature.) + */ +export function flattenParams(params, prefix = '') { + const pairs = []; + if (params === null || params === undefined) return pairs; + + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined) continue; + const name = prefix ? `${prefix}[${key}]` : key; + + if (Array.isArray(value) || (typeof value === 'object')) { + pairs.push(...flattenParams(value, name)); + } else { + pairs.push([name, String(value)]); + } + } + return pairs; +} + /** * OAuth 1.0a Authentication Handler (SECONDARY METHOD) * More complex but provides additional security features @@ -91,31 +129,34 @@ export class OAuth1Handler { throw new Error('Invalid OAuth parameters: method, url, timestamp, and nonce are required'); } - // Combine all parameters - const allParams = { - ...params, - oauth_consumer_key: this.consumerKey, - oauth_timestamp: timestamp, - oauth_nonce: nonce, - oauth_signature_method: 'HMAC-SHA1', - oauth_version: '1.0' - }; + // Flatten request params to the same bracket-index pairs PHP will + // parse from the query string, then add the oauth_* protocol params. + const pairs = [ + ...flattenParams(params), + ['oauth_consumer_key', this.consumerKey], + ['oauth_timestamp', timestamp], + ['oauth_nonce', nonce], + ['oauth_signature_method', 'HMAC-SHA1'], + ['oauth_version', '1.0'], + ]; - // Create parameter string - const paramString = Object.keys(allParams) - .sort() - .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(allParams[key])}`) + // RFC 5849 §3.4.1.3.2: encode first, then sort by encoded name + // (ties broken by encoded value), then join. + const paramString = pairs + .map(([key, value]) => [rfc3986Encode(key), rfc3986Encode(value)]) + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)) + .map((pair) => pair.join('=')) .join('&'); // Create signature base string const baseString = [ method.toUpperCase(), - encodeURIComponent(url), - encodeURIComponent(paramString) + rfc3986Encode(url), + rfc3986Encode(paramString) ].join('&'); // Create signing key - const signingKey = `${encodeURIComponent(this.consumerSecret)}&`; + const signingKey = `${rfc3986Encode(this.consumerSecret)}&`; // Generate signature const signature = crypto diff --git a/src/gravity-forms-client.js b/src/gravity-forms-client.js index e2cd585..dec95ad 100644 --- a/src/gravity-forms-client.js +++ b/src/gravity-forms-client.js @@ -6,7 +6,7 @@ import axios from 'axios'; import https from 'https'; -import { AuthManager, validateRestApiAccess } from './config/auth.js'; +import { AuthManager, validateRestApiAccess, flattenParams, rfc3986Encode } from './config/auth.js'; import { ValidationFactory } from './config/validation.js'; import logger from './utils/logger.js'; import { sanitizeUrl, sanitizeHeaders } from './utils/sanitize.js'; @@ -28,6 +28,17 @@ export class GravityFormsClient { 'User-Agent': 'GravityKit-MCP/2.1.0', 'Accept': 'application/json' }, + // Serialize query params as explicit bracket-index pairs + // (include[0]=3, paging[page_size]=2) — the exact pairs + // flattenParams() feeds the OAuth signature, so the signed + // string and the wire string can never diverge. PHP parses + // either bracket style identically, so Basic-auth requests + // are unaffected. + paramsSerializer: { + serialize: (params) => flattenParams(params) + .map(([key, value]) => `${rfc3986Encode(key)}=${rfc3986Encode(value)}`) + .join('&'), + }, // Allow self-signed certificates for local development // Set GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true in .env for local dev environments httpsAgent: new https.Agent({ @@ -642,8 +653,15 @@ export class GravityFormsClient { return this.validateAndCall('gf_list_feeds', params, async (validated) => { const response = await this.httpClient.get('/feeds', { params: validated }); + // A site with no feeds returns a serialized WP_Error + // ({ errors: { not_found: ["Feed not found"] } }) with HTTP 200 + // instead of an empty collection — normalize to [] so callers + // always get an array. + const data = response.data; + const isEmptyNotFound = data && !Array.isArray(data) && data.errors?.not_found; + return { - feeds: response.data + feeds: isEmptyNotFound ? [] : data }; }); } From eb2cb6be657b76d828d180238d2bc2405b65271f Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 12 Jun 2026 14:17:52 -0400 Subject: [PATCH 15/36] =?UTF-8?q?feat(auth):=20credential-aware=20method?= =?UTF-8?q?=20selection=20=E2=80=94=20Basic=20works=20without=20OAuth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Basic auth no longer hard-requires HTTPS client-side. Selection now matches what the Gravity Forms server actually accepts: - ck_/cs_ key pairs on plain HTTP sign with OAuth 1.0a automatically — GF only checks key-pair Basic auth over SSL (class-gf-rest-authentication.php: if ( is_ssl() )), so Basic with keys on http authenticates as nobody. - WordPress app-password credentials (username + application password) use Basic on local URLs (localhost, *.localhost, 127.x, ::1, *.test, *.local) with no opt-in — WP core authenticates them and GF's capability checks take over. No OAuth involved. - Explicit GRAVITY_FORMS_AUTH_METHOD always wins; remote-HTTP Basic needs GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true and logs a warning. Integration harness: honor GRAVITY_FORMS_TEST_AUTH_METHOD and stop forcing an explicit 'basic' default that defeated auto-selection. New security coverage (skip cleanly without fixtures): unauthenticated requests denied; authenticated user without GF capabilities denied; read-only API key can read but not write. Live verification on minted GF 2.10.3 (Siteminter, http://localhost): 27/27 in all four configurations — auto→OAuth (keys), auto→Basic (app password), explicit oauth, explicit basic. Mocked suites: unit 269, auth 25, all green. --- .env.example | 7 +++ src/config/auth.js | 83 +++++++++++++++++++++++++++----- src/tests/authentication.test.js | 47 +++++++++++++++++- src/tests/integration.test.js | 68 +++++++++++++++++++++++++- 4 files changed, 190 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 3d59ca0..65661a0 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,10 @@ GRAVITY_FORMS_TEST_CONSUMER_SECRET=cs_test_secret_here # GRAVITYKIT_WP_USERNAME=admin # GRAVITYKIT_WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx" # GRAVITYKIT_TIMEOUT=30000 + +# Security-coverage fixtures for the integration suite (all optional — +# the deny tests skip cleanly when unset): +# GRAVITY_FORMS_TEST_LOWPRIV_USER=subscriber_login +# GRAVITY_FORMS_TEST_LOWPRIV_APP_PASSWORD="app password for a user WITHOUT GF capabilities" +# GRAVITY_FORMS_TEST_READONLY_CONSUMER_KEY=ck_key_with_read_permissions +# GRAVITY_FORMS_TEST_READONLY_CONSUMER_SECRET=cs_matching_secret diff --git a/src/config/auth.js b/src/config/auth.js index 5294e0d..cfa5d7e 100644 --- a/src/config/auth.js +++ b/src/config/auth.js @@ -9,19 +9,41 @@ import crypto from 'crypto'; import logger from '../utils/logger.js'; +/** + * True for URLs whose traffic never leaves the machine / LAN dev box: + * localhost, *.localhost, 127.0.0.0/8, [::1], and the conventional dev + * TLDs *.test and *.local. Same idea WordPress core applies when it + * allows application passwords without HTTPS in local environments. + */ +export function isLocalUrl(baseUrl) { + try { + const { hostname } = new URL(baseUrl); + return hostname === 'localhost' + || hostname === '[::1]' + || hostname === '::1' + || hostname.endsWith('.localhost') + || hostname.startsWith('127.') + || hostname.endsWith('.test') + || hostname.endsWith('.local'); + } catch { + return false; + } +} + /** * Basic Authentication Handler (PRIMARY METHOD) - * Simple and secure authentication using Consumer Key/Secret over HTTPS - * Recommended for Gravity Forms v2 REST API + * Simple authentication using Consumer Key/Secret. Requires HTTPS for + * remote hosts; allowed over plain HTTP for local URLs or when the + * caller explicitly opts in (credentials ride base64-encoded on every + * request, so an untrusted network must not see them in the clear). */ export class BasicAuthHandler { - constructor(consumerKey, consumerSecret, baseUrl) { + constructor(consumerKey, consumerSecret, baseUrl, { allowHttp = false } = {}) { this.consumerKey = consumerKey; this.consumerSecret = consumerSecret; this.baseUrl = baseUrl; - // Validate HTTPS for Basic Auth security - if (!this.baseUrl.startsWith('https://')) { + if (!this.baseUrl.startsWith('https://') && !allowHttp) { throw new Error('Basic Authentication requires HTTPS connection for security'); } } @@ -278,14 +300,51 @@ export class AuthManager { GRAVITY_FORMS_BASE_URL ); } else { - if (this.config.GRAVITY_FORMS_DEBUG === 'true') { - logger.info('🔐 Using Basic Authentication (Recommended for Gravity Forms v2)'); + const isHttps = GRAVITY_FORMS_BASE_URL.startsWith('https://'); + const explicitBasic = this.config.GRAVITY_FORMS_AUTH_METHOD?.toLowerCase() === 'basic'; + // GF's own key pairs are always ck_/cs_-prefixed (GFWebAPI + // rand_hash generator). The GF server only CHECKS key-pair Basic + // auth over SSL (class-gf-rest-authentication.php: if (is_ssl())), + // so on plain HTTP a key pair must sign with OAuth 1.0a — Basic + // would silently authenticate as nobody. WordPress application + // passwords (username + app password) authenticate through WP + // core instead and DO work over Basic on local HTTP. + const isGfKeyPair = /^ck_/.test(GRAVITY_FORMS_CONSUMER_KEY || '') + && /^cs_/.test(GRAVITY_FORMS_CONSUMER_SECRET || ''); + + if (!isHttps && isGfKeyPair && !explicitBasic) { + if (this.config.GRAVITY_FORMS_DEBUG === 'true') { + logger.info('🔐 Consumer key pair over HTTP — using OAuth 1.0a (Gravity Forms only accepts key-pair Basic auth over HTTPS)'); + } + this.authHandler = new OAuth1Handler( + GRAVITY_FORMS_CONSUMER_KEY, + GRAVITY_FORMS_CONSUMER_SECRET, + GRAVITY_FORMS_BASE_URL + ); + } else { + if (this.config.GRAVITY_FORMS_DEBUG === 'true') { + logger.info('🔐 Using Basic Authentication (Recommended for Gravity Forms v2)'); + } + // Basic over plain HTTP is allowed when the traffic can't + // leave the machine (localhost / *.test / *.local), when the + // user explicitly chose basic, or via the env opt-in — the + // silent default on a remote http:// URL still falls back to + // OAuth below. + const allowHttp = isLocalUrl(GRAVITY_FORMS_BASE_URL) + || explicitBasic + || this.config.GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH === 'true'; + + if (allowHttp && !isHttps && !isLocalUrl(GRAVITY_FORMS_BASE_URL)) { + logger.warn('⚠️ Basic Authentication over plain HTTP to a remote host — credentials are visible to the network. Use HTTPS when possible.'); + } + + this.authHandler = new BasicAuthHandler( + GRAVITY_FORMS_CONSUMER_KEY, + GRAVITY_FORMS_CONSUMER_SECRET, + GRAVITY_FORMS_BASE_URL, + { allowHttp } + ); } - this.authHandler = new BasicAuthHandler( - GRAVITY_FORMS_CONSUMER_KEY, - GRAVITY_FORMS_CONSUMER_SECRET, - GRAVITY_FORMS_BASE_URL - ); } } catch (error) { // Fallback to OAuth if Basic Auth fails (e.g., HTTP instead of HTTPS) diff --git a/src/tests/authentication.test.js b/src/tests/authentication.test.js index 0ca2ecb..8521f95 100644 --- a/src/tests/authentication.test.js +++ b/src/tests/authentication.test.js @@ -183,7 +183,21 @@ suite.test('AuthManager: Should use OAuth when specified', () => { TestAssert.isFalse(info.recommended); }); -suite.test('AuthManager: Should fallback to OAuth for HTTP URLs', () => { +suite.test('AuthManager: remote HTTP with default method falls back to OAuth', () => { + const config = { + ...testEnv, + GRAVITY_FORMS_BASE_URL: 'http://insecure.com' + }; + delete config.GRAVITY_FORMS_AUTH_METHOD; + + const manager = new AuthManager(config); + const info = manager.getAuthInfo(); + + TestAssert.equal(info.method, 'OAuth 1.0a'); + TestAssert.isFalse(info.secure); +}); + +suite.test('AuthManager: explicit basic is honored over remote HTTP', () => { const config = { ...testEnv, GRAVITY_FORMS_BASE_URL: 'http://insecure.com', @@ -193,10 +207,39 @@ suite.test('AuthManager: Should fallback to OAuth for HTTP URLs', () => { const manager = new AuthManager(config); const info = manager.getAuthInfo(); - TestAssert.equal(info.method, 'OAuth 1.0a'); + TestAssert.equal(info.method, 'Basic Authentication'); TestAssert.isFalse(info.secure); }); +suite.test('AuthManager: ck_/cs_ key pair over HTTP auto-selects OAuth (GF only checks key Basic over SSL)', () => { + for (const baseUrl of ['http://localhost:8893', 'http://dev.test', 'http://remote.example.com']) { + const config = { ...testEnv, GRAVITY_FORMS_BASE_URL: baseUrl }; + delete config.GRAVITY_FORMS_AUTH_METHOD; // ck_test_key / cs_test_secret from testEnv + + const manager = new AuthManager(config); + const info = manager.getAuthInfo(); + + TestAssert.equal(info.method, 'OAuth 1.0a', `${baseUrl} with a key pair should sign with OAuth`); + } +}); + +suite.test('AuthManager: app-password credentials get Basic on local HTTP (WP core auth path)', () => { + for (const baseUrl of ['http://localhost:8893', 'http://dev.test', 'http://127.0.0.1:8080', 'http://mysite.local']) { + const config = { + ...testEnv, + GRAVITY_FORMS_BASE_URL: baseUrl, + GRAVITY_FORMS_CONSUMER_KEY: 'admin', + GRAVITY_FORMS_CONSUMER_SECRET: 'abcd efgh ijkl mnop qrst uvwx' + }; + delete config.GRAVITY_FORMS_AUTH_METHOD; + + const manager = new AuthManager(config); + const info = manager.getAuthInfo(); + + TestAssert.equal(info.method, 'Basic Authentication', `${baseUrl} with app-password creds should use Basic`); + } +}); + suite.test('AuthManager: Should remove trailing slash from base URL', () => { const config = { ...testEnv, diff --git a/src/tests/integration.test.js b/src/tests/integration.test.js index f119b5c..b08286d 100644 --- a/src/tests/integration.test.js +++ b/src/tests/integration.test.js @@ -51,7 +51,9 @@ suite.beforeAll(async () => { GRAVITY_FORMS_CONSUMER_KEY: process.env.GRAVITY_FORMS_TEST_CONSUMER_KEY, GRAVITY_FORMS_CONSUMER_SECRET: process.env.GRAVITY_FORMS_TEST_CONSUMER_SECRET, GRAVITY_FORMS_BASE_URL: process.env.GRAVITY_FORMS_TEST_BASE_URL, - GRAVITY_FORMS_AUTH_METHOD: process.env.GRAVITY_FORMS_AUTH_METHOD || 'basic', + // Leave unset for credential-aware auto-selection; an explicit + // method (TEST_ variant wins) is passed through as-is. + GRAVITY_FORMS_AUTH_METHOD: process.env.GRAVITY_FORMS_TEST_AUTH_METHOD || process.env.GRAVITY_FORMS_AUTH_METHOD, GRAVITY_FORMS_ALLOW_DELETE: 'true' // Enable for cleanup }); @@ -710,6 +712,70 @@ suite.test('Integration: Test entries pagination with paging parameters', async } }); + +// =================================================================== +// Security: deny-paths. Every auth method must REJECT requests that +// are unauthenticated, carry bad credentials, lack GF capabilities, +// or exceed their key's permission level. +// =================================================================== + +suite.test('Security: unauthenticated request is denied', async () => { + const response = await fetch(`${process.env.GRAVITY_FORMS_TEST_BASE_URL}/wp-json/gf/v2/forms`); + TestAssert.isTrue( + response.status === 401 || response.status === 403, + `Unauthenticated /forms must be 401/403, got ${response.status}` + ); +}); + +suite.test('Security: authenticated user WITHOUT GF capabilities is denied', async () => { + const user = process.env.GRAVITY_FORMS_TEST_LOWPRIV_USER; + const pass = process.env.GRAVITY_FORMS_TEST_LOWPRIV_APP_PASSWORD; + if (!user || !pass) { + console.log(' Skipping - set GRAVITY_FORMS_TEST_LOWPRIV_USER / _APP_PASSWORD (e.g. a subscriber app password)'); + return; + } + + const auth = Buffer.from(`${user}:${pass}`).toString('base64'); + const response = await fetch(`${process.env.GRAVITY_FORMS_TEST_BASE_URL}/wp-json/gf/v2/forms`, { + headers: { Authorization: `Basic ${auth}` } + }); + TestAssert.isTrue( + response.status === 401 || response.status === 403, + `Subscriber on /forms must be 401/403, got ${response.status}` + ); +}); + +suite.test('Security: read-only API key cannot write', async () => { + const roKey = process.env.GRAVITY_FORMS_TEST_READONLY_CONSUMER_KEY; + const roSecret = process.env.GRAVITY_FORMS_TEST_READONLY_CONSUMER_SECRET; + if (!roKey || !roSecret) { + console.log(' Skipping - set GRAVITY_FORMS_TEST_READONLY_CONSUMER_KEY / _SECRET (a key with "read" permissions)'); + return; + } + + // No AUTH_METHOD: credential-aware auto-selection picks the right + // transport for the key pair (OAuth on http, Basic on https). + const roClient = new GravityFormsClient({ + GRAVITY_FORMS_BASE_URL: process.env.GRAVITY_FORMS_TEST_BASE_URL, + GRAVITY_FORMS_CONSUMER_KEY: roKey, + GRAVITY_FORMS_CONSUMER_SECRET: roSecret + }); + await roClient.initialize().catch(() => {}); + + // Reads must work… (GF /forms returns an ID-keyed object, not an array) + const listed = await roClient.listForms({}); + TestAssert.isTrue(!!listed.forms && typeof listed.forms === 'object', 'read-only key should list forms'); + + // …writes must not. + let writeDenied = false; + try { + await roClient.createForm({ title: 'TEST_should_never_exist' }); + } catch (e) { + writeDenied = true; + } + TestAssert.isTrue(writeDenied, 'read-only key must be denied on createForm'); +}); + // Run tests if executed directly if (import.meta.url === `file://${process.argv[1]}`) { suite.run().then((results) => { From e0381948daa5d1d0702766582b7a81a96a88f3c4 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 12 Jun 2026 14:24:42 -0400 Subject: [PATCH 16/36] docs: lead with application passwords; document credential-aware auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App passwords are now the recommended first-run credential (README, .env.example, AGENTS.md): one credential powers gf_* and, with Foundation, the GravityKit product tools; access follows WP capabilities. GF API keys move to the scoped-access (e.g. read-only) path. Documented the one unavoidable GF requirement — 'Enable access to the API' gates route registration for every credential type. Removed the active GRAVITY_FORMS_AUTH_METHOD=basic line from .env.example: with explicit method now honored everywhere, shipping it as a default forces Basic on remote HTTP. Replaced the stale 'silently falls back to OAuth' gotcha with the credential-aware rules. --- .env.example | 32 ++++++++++++++++++++++-------- AGENTS.md | 13 +++++++++---- README.md | 55 ++++++++++++++++++++++++++++++++++++---------------- 3 files changed, 71 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index 65661a0..8bbf4a0 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,25 @@ # GravityKit MCP Configuration # ============================================================ -# REQUIRED: Gravity Forms REST API v2 Credentials -# Generate these in WordPress admin: Forms > Settings > REST API +# REQUIRED: WordPress credentials +# +# Recommended: a WordPress application password (Users > Profile > +# Application Passwords). KEY = your username, SECRET = the generated +# password. Access follows your WordPress capabilities, and on sites +# running GravityKit Foundation the same credential powers the +# GravityKit product tools too. +# +# Alternative (scoped access, e.g. read-only): a Gravity Forms API +# key from Forms > Settings > REST API (ck_... / cs_...). +# +# Either way, check "Enable access to the API" on Forms > Settings > +# REST API once — Gravity Forms doesn't register its REST routes +# without it. # ============================================================ -GRAVITY_FORMS_CONSUMER_KEY=ck_your_consumer_key_here -GRAVITY_FORMS_CONSUMER_SECRET=cs_your_consumer_secret_here +GRAVITY_FORMS_CONSUMER_KEY=your_wp_username +GRAVITY_FORMS_CONSUMER_SECRET="xxxx xxxx xxxx xxxx xxxx xxxx" +# GRAVITY_FORMS_CONSUMER_KEY=ck_your_consumer_key_here +# GRAVITY_FORMS_CONSUMER_SECRET=cs_your_consumer_secret_here # Required: Your WordPress site URL (no trailing slash) GRAVITY_FORMS_BASE_URL=https://yoursite.com @@ -18,10 +32,12 @@ GRAVITY_FORMS_BASE_URL=https://yoursite.com # ============================================================ # AUTHENTICATION # ============================================================ -# Method: 'basic' (default, recommended) or 'oauth' / 'oauth1' -# Basic Auth requires HTTPS. If the site uses HTTP, the server -# silently falls back to OAuth 1.0a (see Gotcha #3 in AGENTS.md). -GRAVITY_FORMS_AUTH_METHOD=basic +# The client auto-selects the transport from your credentials: +# app-password creds use Basic (HTTPS or local URLs); ck_/cs_ key +# pairs use Basic on HTTPS and OAuth 1.0a on plain HTTP (Gravity +# Forms only accepts key-pair Basic auth over HTTPS). Set this ONLY +# to override — an explicit value is always honored, everywhere. +# GRAVITY_FORMS_AUTH_METHOD=basic # ============================================================ # SECURITY diff --git a/AGENTS.md b/AGENTS.md index baf6c8c..8b59227 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ MCP/ **GravityFormsClient** (`gravity-forms-client.js`): Single class wrapping all API endpoints. Each method uses `validateAndCall(toolName, input, apiCall)` pattern — validates input via `ValidationFactory`, then executes the HTTP call. Update operations (forms, entries, feeds) fetch-then-merge to preserve existing data. Responses return minimal payloads — just the essential data without redundant metadata. -**AuthManager** (`config/auth.js`): Selects between `BasicAuthHandler` (primary, requires HTTPS) and `OAuth1Handler` (fallback). Auto-falls-back to OAuth if HTTPS isn't available. Auth headers injected via axios request interceptor. +**AuthManager** (`config/auth.js`): Credential-aware selection between `BasicAuthHandler` and `OAuth1Handler` — app-password creds get Basic (HTTPS or local URLs); `ck_`/`cs_` key pairs get Basic on HTTPS, OAuth on plain HTTP (matching the GF server's `is_ssl()` gate for key Basic auth). Explicit `GRAVITY_FORMS_AUTH_METHOD` overrides. Auth headers injected via axios request interceptor. **ValidationFactory** (`config/validation.js`): Central validation dispatcher. `validateToolInput(toolName, input)` routes to domain-specific validators. Composable rule chains (`validation-chain.js`) for reusable validation logic. @@ -286,9 +286,14 @@ npm run inspect # Debug with MCP Inspector ### Required Environment ``` -GRAVITY_FORMS_CONSUMER_KEY=ck_... # From WP Admin > Forms > Settings > REST API -GRAVITY_FORMS_CONSUMER_SECRET=cs_... # Same location GRAVITY_FORMS_BASE_URL=https://... # WordPress site URL (no trailing slash) +# Recommended — WordPress application password (Users > Profile): +GRAVITY_FORMS_CONSUMER_KEY=wp_username +GRAVITY_FORMS_CONSUMER_SECRET="xxxx xxxx xxxx xxxx xxxx xxxx" +# Or a Gravity Forms API key (Forms > Settings > REST API), e.g. read-only: +# GRAVITY_FORMS_CONSUMER_KEY=ck_... +# GRAVITY_FORMS_CONSUMER_SECRET=cs_... +# Either way: check "Enable access to the API" on Forms > Settings > REST API once. ``` Shorthand aliases `GF_CONSUMER_KEY`, `GF_CONSUMER_SECRET`, `GF_URL` are also supported (resolved in `test-config.js`). @@ -348,7 +353,7 @@ No build step — pure ESM JavaScript, runs directly with `node src/index.js`. R 2. **Stdout is reserved for JSON-RPC.** In MCP mode, ALL logging must go to stderr. Using `console.log` will corrupt the JSON-RPC transport. The `logger.js` utility handles this, but any new code must use `logger.info/error/warn` instead of `console.log`. — `utils/logger.js:14-32` -3. **Auth fallback is silent.** If Basic Auth fails because the site uses HTTP (not HTTPS), `AuthManager` silently falls back to OAuth 1.0a. Only warns in non-test mode. This can cause confusing auth failures if OAuth credentials aren't properly configured. — `config/auth.js:250-267` +3. **Auth method is credential-aware.** `AuthManager` picks the transport from the credential shape: app-password creds (username + application password) use Basic over HTTPS or local URLs; `ck_`/`cs_` key pairs use Basic over HTTPS and OAuth 1.0a over plain HTTP (Gravity Forms only checks key-pair Basic auth when `is_ssl()`). An explicit `GRAVITY_FORMS_AUTH_METHOD` is always honored — including `basic` over remote HTTP, so don't set it in `.env` "just in case". Remote-HTTP Basic without an explicit method needs `GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true`. — `config/auth.js` 4. **Update operations fetch-then-merge.** `updateForm`, `updateEntry`, and `updateFeed` all GET the existing resource first, merge updates, then PUT. This prevents data loss but means two HTTP calls per update. If the resource is modified between GET and PUT, the intermediate change is overwritten. — `gravity-forms-client.js:262-278` diff --git a/README.md b/README.md index 17841b7..0a33359 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit ### Prerequisites - Node.js 18+ - WordPress with Gravity Forms 2.5+ -- HTTPS-enabled WordPress site (required for authentication) +- WordPress site over HTTPS (recommended) — local `http://` dev sites (localhost, `*.test`, `*.local`) work too ### Installation @@ -37,11 +37,31 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit cp .env.example .env ``` -3. **Configure credentials** in `.env`: +3. **Enable the Gravity Forms REST API** (one-time, required for any credential type): + - Go to **Forms → Settings → REST API** and check **Enable access to the API** — Gravity Forms doesn't register its REST routes without it. + +4. **Create credentials** in WordPress (pick one): + + **Application password (recommended):** + - Go to **Users → Profile → Application Passwords** + - Name it (e.g. `GravityKit MCP`) and click **Add New Application Password** + - Copy the generated password. Your username + this password are your credentials, and access follows your WordPress capabilities. On sites running GravityKit Foundation, the same credential also powers the GravityKit product tools. + + **Gravity Forms API key (for scoped access, e.g. a read-only key):** + - On the same **Forms → Settings → REST API** screen, click **Add Key** + - Choose the user and permission level, then save the Consumer Key (`ck_…`) and Secret (`cs_…`) + +5. **Configure credentials** in `.env`: ```env - GRAVITY_FORMS_CONSUMER_KEY=your_key_here - GRAVITY_FORMS_CONSUMER_SECRET=your_secret_here GRAVITY_FORMS_BASE_URL=https://yoursite.com + + # Application password: + GRAVITY_FORMS_CONSUMER_KEY=your_wp_username + GRAVITY_FORMS_CONSUMER_SECRET="xxxx xxxx xxxx xxxx xxxx xxxx" + + # …or a Gravity Forms API key: + # GRAVITY_FORMS_CONSUMER_KEY=ck_your_key + # GRAVITY_FORMS_CONSUMER_SECRET=cs_your_secret ``` **For local development** (Laravel Valet, MAMP, etc.): @@ -50,12 +70,7 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true ``` -4. **Generate API credentials** in WordPress: - - Go to **Forms → Settings → REST API** - - Click **Add Key** - - Save the Consumer Key and Secret - -5. **Add to Claude Desktop** +6. **Add to Claude Desktop** Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json @@ -65,8 +80,8 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit "command": "node", "args": ["/path/to/MCP/src/index.js"], "env": { - "GRAVITY_FORMS_CONSUMER_KEY": "your_key", - "GRAVITY_FORMS_CONSUMER_SECRET": "your_secret", + "GRAVITY_FORMS_CONSUMER_KEY": "your_wp_username", + "GRAVITY_FORMS_CONSUMER_SECRET": "xxxx xxxx xxxx xxxx xxxx xxxx", "GRAVITY_FORMS_BASE_URL": "https://yoursite.com" } } @@ -150,12 +165,13 @@ await mcp.call('gf_submit_form_data', { ## Configuration ### Required Environment Variables -- `GRAVITY_FORMS_CONSUMER_KEY` - API consumer key -- `GRAVITY_FORMS_CONSUMER_SECRET` - API consumer secret +- `GRAVITY_FORMS_CONSUMER_KEY` - WordPress username (app-password setup) or GF consumer key (`ck_…`) +- `GRAVITY_FORMS_CONSUMER_SECRET` - Application password or GF consumer secret (`cs_…`) - `GRAVITY_FORMS_BASE_URL` - WordPress site URL ### Optional Settings -- `GRAVITY_FORMS_AUTH_METHOD=basic` - Auth method: `basic` (recommended) or `oauth`/`oauth1` +- `GRAVITY_FORMS_AUTH_METHOD` - Override auto-selection: `basic` or `oauth`/`oauth1` (normally leave unset) +- `GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=false` - Allow Basic auth to a REMOTE plain-HTTP host (credentials visible to the network) - `GRAVITY_FORMS_ALLOW_DELETE=false` - Enable delete operations - `GRAVITY_FORMS_TIMEOUT=30000` - Request timeout (ms) - `GRAVITY_FORMS_MAX_RETRIES=3` - Max retry attempts for failed requests @@ -164,9 +180,14 @@ await mcp.call('gf_submit_form_data', { ### Authentication Flow -The server uses **Basic Authentication** by default (recommended for Gravity Forms v2). If the site uses HTTP instead of HTTPS, Basic Auth cannot be used and the server **silently falls back to OAuth 1.0a**. Set `GRAVITY_FORMS_AUTH_METHOD=oauth` to force OAuth 1.0a. +The client picks the right transport from the shape of your credentials — you normally don't configure anything: + +- **Application password** (username + app password) → **Basic Authentication** over HTTPS or any local URL (localhost, `*.test`, `*.local`). WordPress core authenticates the user; Gravity Forms enforces their capabilities. +- **Gravity Forms key pair** (`ck_…`/`cs_…`) over HTTPS → **Basic Authentication**. +- **Gravity Forms key pair** over plain HTTP → **OAuth 1.0a**, automatically — Gravity Forms only accepts key-pair Basic auth over HTTPS, so OAuth is the only transport that works there. +- **Remote plain-HTTP host** → OAuth for key pairs; Basic requires the explicit `GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true` opt-in and logs a warning. -Both methods use the same Consumer Key and Consumer Secret generated in WordPress admin under Forms > Settings > REST API. +Set `GRAVITY_FORMS_AUTH_METHOD` only to override the auto-selection. Whatever the transport, access is limited to the WordPress user's capabilities (or the API key's permission level). ## Test Environment Configuration From ec9698f5a85a009acf01d9e4b93ed7297c736b67 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 12 Jun 2026 14:24:58 -0400 Subject: [PATCH 17/36] docs(agents): optional-env block matches credential-aware auth --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8b59227..db88b7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,7 +301,8 @@ Shorthand aliases `GF_CONSUMER_KEY`, `GF_CONSUMER_SECRET`, `GF_URL` are also sup ### Optional Environment ``` -GRAVITY_FORMS_AUTH_METHOD=basic # 'basic' (default) or 'oauth'/'oauth1' +# GRAVITY_FORMS_AUTH_METHOD=basic # Override auto-selection only (see Gotcha #3) +# GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=false # Basic to a REMOTE plain-HTTP host GRAVITY_FORMS_ALLOW_DELETE=false # Must be 'true' to enable delete operations GRAVITY_FORMS_TIMEOUT=30000 # Request timeout in ms GRAVITY_FORMS_MAX_RETRIES=3 # Max retry attempts From afed2388335d740879d1129ff74a12eacc624eb3 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 17:07:39 -0400 Subject: [PATCH 18/36] test(views): assert actual 'requires credentials' error substring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constructor throws 'WordPress client requires credentials…' but the test asserted the substring 'WordPress credentials', which never appears. Align with the actual message (and with the sibling base-URL test, which asserts a literal substring of its error). --- src/tests/views.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/views.test.js b/src/tests/views.test.js index 8284c8d..27ddce0 100644 --- a/src/tests/views.test.js +++ b/src/tests/views.test.js @@ -43,7 +43,7 @@ suite.test('Constructor: throws without a base URL', () => { suite.test('Constructor: throws without credentials', () => { TestAssert.throws( () => new GravityViewInspectorClient({ GRAVITYKIT_WP_URL: 'https://example.com' }), - 'WordPress credentials' + 'requires credentials' ); }); From 87243eac50dc054654fb1c82165447c25318fed3 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 17:35:10 -0400 Subject: [PATCH 19/36] fix(feeds): tolerate sites with no feed add-on / feed table Gravity Forms returns a serialized WP_Error with HTTP 200 when feeds can't be enumerated. listFeeds already normalized the not_found variant to []; a fresh GF install with no feed-based add-on instead returns missing_table (the wp_gf_addon_feed table only exists once a GFFeedAddOn's upgrade_base() has run). Generalize the normalization to any HTTP-200 WP_Error so gf_list_feeds always returns an array on any site. Integration test: the "Create test feed" pre-check listed MailChimp feeds to detect availability, but with the normalization above that now returns {feeds: []} on a fresh site and the check falsely concluded MailChimp was present, then hard-failed on create. Replace the brittle pre-check with an attempt-and-skip: try the create, and skip (not fail) when the error shows the add-on or its table isn't available. Verified live against a Siteminter WP site across no-table, table-present, and add-on-inactive states (27/27). --- src/gravity-forms-client.js | 12 ++++++------ src/tests/integration.test.js | 31 +++++++++++++++++-------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/gravity-forms-client.js b/src/gravity-forms-client.js index dec95ad..655740a 100644 --- a/src/gravity-forms-client.js +++ b/src/gravity-forms-client.js @@ -653,15 +653,15 @@ export class GravityFormsClient { return this.validateAndCall('gf_list_feeds', params, async (validated) => { const response = await this.httpClient.get('/feeds', { params: validated }); - // A site with no feeds returns a serialized WP_Error - // ({ errors: { not_found: ["Feed not found"] } }) with HTTP 200 - // instead of an empty collection — normalize to [] so callers - // always get an array. + // Gravity Forms signals "zero feeds" as a serialized WP_Error with + // HTTP 200 (not_found = none match; missing_table = no feed add-on has + // ever run). Normalize any HTTP-200 WP_Error to [] so callers always + // get an array; real failures arrive as non-200 and throw before here. const data = response.data; - const isEmptyNotFound = data && !Array.isArray(data) && data.errors?.not_found; + const isEmptyWpError = data && !Array.isArray(data) && !!data.errors; return { - feeds: isEmptyNotFound ? [] : data + feeds: isEmptyWpError ? [] : data }; }); } diff --git a/src/tests/integration.test.js b/src/tests/integration.test.js index b08286d..355a090 100644 --- a/src/tests/integration.test.js +++ b/src/tests/integration.test.js @@ -375,19 +375,8 @@ suite.test('Integration: List available feeds', async () => { }); suite.test('Integration: Create test feed (if MailChimp available)', async () => { - // First check if MailChimp addon is available by trying to list its feeds - let mailchimpAvailable = false; - try { - const feeds = await client.listFeeds({ addon: 'gravityformsmailchimp' }); - // If we get here without error, addon might be available - mailchimpAvailable = !feeds.error || !feeds.error.includes('not installed'); - } catch (error) { - // Addon check failed, likely not available - mailchimpAvailable = false; - } - - if (!mailchimpAvailable) { - console.log(' MailChimp addon not installed - skipping feed test'); + if (!testFormId) { + console.log(' No test form available - skipping feed creation'); return; } @@ -403,7 +392,21 @@ suite.test('Integration: Create test feed (if MailChimp available)', async () => } }; - const result = await client.createFeed(feedData); + // Attempt creation and skip (don't fail) when the feed add-on or its + // infrastructure isn't present: a fresh Gravity Forms install has no + // wp_gf_addon_feed table, and MailChimp may not be installed at all. + let result; + try { + result = await client.createFeed(feedData); + } catch (error) { + const msg = error.message || ''; + const unavailable = /table does not exist|missing_table|not installed|not active|invalid add-?on|add-?on .*not (registered|found)/i.test(msg); + if (unavailable) { + console.log(` MailChimp feed add-on not available - skipping (${msg})`); + return; + } + throw error; + } TestAssert.isNotNull(result.feed, 'Feed should be created'); TestAssert.isNotNull(result.feed.id, 'Should return feed ID'); From ded240c88d2f0ed8c9060bea18b430592760cce1 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 17:35:11 -0400 Subject: [PATCH 20/36] fix(demo): align demo-abilities.mjs with current catalog [ci skip] The abilities catalog renamed tools from verb-noun to noun-verb (list-layouts -> layouts-list, create-view -> view-create, etc.); the demo still used the old ability and gv_* handler names and crashed at step 1c. Update all names to match the live catalog, replace hardcoded absolute import paths with relative ones, and make the View round-trip self-contained by minting a throwaway form (when GRAVITYKIT_DEMO_FORM_ID is unset) and cleaning up both the View and the form at the end. Verified end-to-end against a live GravityView 3.0.0 site. --- demo-abilities.mjs | 86 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/demo-abilities.mjs b/demo-abilities.mjs index 41018fe..cb88171 100644 --- a/demo-abilities.mjs +++ b/demo-abilities.mjs @@ -6,13 +6,19 @@ * round-trip create + apply + render — same path the MCP and the * Design Studio React app now use in production. * - * Run from /Users/zackkatz/Dropbox/MonoKit/MCPs/gravitymcp: - * node /tmp/abilities-demo.mjs + * Run from the repo root: + * node demo-abilities.mjs + * + * Needs WordPress creds in the environment (or .env): GRAVITYKIT_WP_URL + + * GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD, or the GRAVITY_FORMS_* + * equivalents. Set GRAVITYKIT_DEMO_FORM_ID to bind the View to an existing + * form; otherwise the demo mints a throwaway form and cleans it up. */ import 'dotenv/config'; -import { WordPressClient } from '/Users/zackkatz/Dropbox/MonoKit/MCPs/gravitymcp/src/wp-client.js'; -import { loadAbilitiesAsTools, methodForAbility } from '/Users/zackkatz/Dropbox/MonoKit/MCPs/gravitymcp/src/abilities/loader.js'; +import { WordPressClient } from './src/wp-client.js'; +import { loadAbilitiesAsTools, methodForAbility } from './src/abilities/loader.js'; +import GravityFormsClient from './src/gravity-forms-client.js'; const RESET = '\x1b[0m'; const DIM = '\x1b[2m'; @@ -46,7 +52,7 @@ header('1. Discover the catalog (single network call)'); // ────────────────────────────────────────────────────────────────── step('1a', 'Fetch /wp-json/wp-abilities/v1/abilities'); -const { definitions, handlers, count } = await loadAbilitiesAsTools(client); +const { handlers, count } = await loadAbilitiesAsTools(client); ok(`${count} abilities discovered under the gk-gravityview/ namespace`); step('1b', 'Categorize them — what can the agent do?'); @@ -65,7 +71,7 @@ for (const cat of Object.keys(byCat).sort()) { } step('1c', 'Show one ability\'s full self-description'); -const sample = ours.find(a => a.name === 'gk-gravityview/list-layouts'); +const sample = ours.find(a => a.name === 'gk-gravityview/layouts-list'); console.log(` ${BOLD}${sample.name}${RESET}`); muted(` ${sample.description.slice(0, 140)}…`); value('annotations', sample.meta?.annotations); @@ -76,9 +82,9 @@ header('2. HTTP method auto-routing (annotations drive the wire)'); // ────────────────────────────────────────────────────────────────── const examples = [ - ours.find(a => a.name === 'gk-gravityview/list-layouts'), // readonly + idempotent → GET - ours.find(a => a.name === 'gk-gravityview/create-view'), // write → POST - ours.find(a => a.name === 'gk-gravityview/remove-view-field'), // destructive + idempotent → DELETE + ours.find(a => a.name === 'gk-gravityview/layouts-list'), // readonly + idempotent → GET + ours.find(a => a.name === 'gk-gravityview/view-create'), // write → POST + ours.find(a => a.name === 'gk-gravityview/view-field-remove'), // destructive + idempotent → DELETE ]; for (const a of examples) { const m = methodForAbility(a.meta?.annotations || {}); @@ -90,8 +96,8 @@ for (const a of examples) { header('3. Run a readonly ability (zero input)'); // ────────────────────────────────────────────────────────────────── -step('3a', 'gv_list_layouts → list installed layout engines'); -const layouts = await handlers.gv_list_layouts({}); +step('3a', 'gv_layouts_list → list installed layout engines'); +const layouts = await handlers.gv_layouts_list({}); ok(`${layouts.layouts.length} layouts returned`); layouts.layouts.slice(0, 4).forEach(l => { console.log(` ${YELLOW}${l.id.padEnd(32)}${RESET} ${l.label}${l.has_grid ? ' ' + GREEN + '[grid]' + RESET : ''}`); @@ -101,8 +107,8 @@ layouts.layouts.slice(0, 4).forEach(l => { header('4. Run a readonly ability with input (bracketed query params)'); // ────────────────────────────────────────────────────────────────── -step('4a', 'gv_get_field_type_schema { field_type: "email" }'); -const emailSchema = await handlers.gv_get_field_type_schema({ field_type: 'email' }); +step('4a', 'gv_field_type_schema_get { field_type: "email" }'); +const emailSchema = await handlers.gv_field_type_schema_get({ field_type: 'email' }); ok(`${emailSchema.schema.length} settings declared for the email field type`); emailSchema.schema.slice(0, 5).forEach(s => muted(` • ${s.slug.padEnd(28)} ${s.type.padEnd(12)} ${s.label || ''}`)); @@ -110,9 +116,24 @@ emailSchema.schema.slice(0, 5).forEach(s => muted(` • ${s.slug.padEnd(28)} header('5. End-to-end round-trip: create → apply → read'); // ────────────────────────────────────────────────────────────────── -step('5a', 'gv_create_view — mint a fresh draft'); -const formId = Number(process.env.GRAVITYKIT_DEMO_FORM_ID || 296); -const created = await handlers.gv_create_view({ +step('5a', 'gv_view_create — mint a fresh draft'); +// Bind to GRAVITYKIT_DEMO_FORM_ID if provided, else mint a throwaway form. +let formId = Number(process.env.GRAVITYKIT_DEMO_FORM_ID || 0); +let tempFormClient = null; +if (!formId) { + tempFormClient = new GravityFormsClient({ ...process.env, GRAVITY_FORMS_ALLOW_DELETE: 'true' }); + await tempFormClient.initialize(); + const f = await tempFormClient.createForm({ + title: 'Abilities API demo form', + fields: [ + { id: 1, type: 'text', label: 'Speaker' }, + { id: 2, type: 'email', label: 'Email' }, + ], + }); + formId = Number(f.form?.id ?? f.id); + ok(`minted throwaway form #${formId}`); +} +const created = await handlers.gv_view_create({ title: `Abilities API demo · ${new Date().toISOString().slice(11, 19)}`, form_id: formId, template_id: 'default_table', @@ -121,8 +142,8 @@ const created = await handlers.gv_create_view({ ok(`view #${created.view_id} created (version ${created.version})`); value('admin URL', created.admin_url || `[edit in WP admin via post id ${created.view_id}]`); -step('5b', 'gv_apply_view_config — add a column with optimistic concurrency'); -const applied = await handlers.gv_apply_view_config({ +step('5b', 'gv_view_config_apply — add a column with optimistic concurrency'); +const applied = await handlers.gv_view_config_apply({ id: created.view_id, fields: { 'directory_table-columns': [{ field_id: '1', slot: 'demo_speaker', custom_label: 'Speaker' }] }, mode: 'merge', @@ -131,8 +152,8 @@ const applied = await handlers.gv_apply_view_config({ ok(`apply landed → version bumped to ${applied.version}`); value('applied envelope', applied.applied); -step('5c', 'gv_get_view_config — read it back'); -const config = await handlers.gv_get_view_config({ id: created.view_id }); +step('5c', 'gv_view_config_get — read it back'); +const config = await handlers.gv_view_config_get({ id: created.view_id }); const slot = config.fields['directory_table-columns']?.demo_speaker; ok(`field present at directory_table-columns.demo_speaker`); value('stored slot', slot); @@ -144,7 +165,7 @@ header('6. Stale ifMatch → server returns 412 (concurrency in action)'); step('6a', 'Apply with the OLD version (now stale after 5b)'); let conflict; try { - await handlers.gv_apply_view_config({ + await handlers.gv_view_config_apply({ id: created.view_id, fields: { 'directory_table-columns': [{ field_id: '1', slot: 'should_fail', custom_label: 'X' }] }, mode: 'merge', @@ -168,16 +189,31 @@ step('7a', 'curl-equivalent GET on a readonly ability'); const direct = await client.httpClient.request({ method: 'GET', baseURL: client.baseUrl, - url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/list-search-zones/run', + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/search-zones-list/run', }); -ok(`HTTP ${direct.status} /wp-abilities/v1/abilities/gk-gravityview/list-search-zones/run`); +ok(`HTTP ${direct.status} /wp-abilities/v1/abilities/gk-gravityview/search-zones-list/run`); value('body', direct.data); step('7b', 'How an external client (curl, Postman, the React app) calls this'); console.log(` ${DIM}curl -u user:pass -X POST \\${RESET}`); -console.log(` ${DIM} https://example.com/wp-json/wp-abilities/v1/abilities/gk-gravityview/apply-view-config/run \\${RESET}`); +console.log(` ${DIM} https://example.com/wp-json/wp-abilities/v1/abilities/gk-gravityview/view-config-apply/run \\${RESET}`); console.log(` ${DIM} -H 'Content-Type: application/json' \\${RESET}`); console.log(` ${DIM} -d '{"input":{"id":${created.view_id},"fields":{...}}}'${RESET}`); // ────────────────────────────────────────────────────────────────── -console.log(`\n${BOLD}${GREEN}Done.${RESET} View #${created.view_id} left in place for inspection.\n`); +header('8. Clean up what the demo created'); +// ────────────────────────────────────────────────────────────────── + +step('8a', 'gv_view_delete — remove the demo View'); +await handlers.gv_view_delete({ id: created.view_id }) + .then(() => ok(`View #${created.view_id} deleted`)) + .catch(err => muted(` view cleanup skipped: ${err.response?.data?.code || err.message}`)); + +if (tempFormClient) { + step('8b', 'Delete the throwaway form'); + await tempFormClient.deleteForm({ id: formId }) + .then(() => ok(`Form #${formId} deleted`)) + .catch(err => muted(` form cleanup skipped: ${err.message}`)); +} + +console.log(`\n${BOLD}${GREEN}Done.${RESET}\n`); From bda10bb7247dab5bf08bcbb0705bdfc25cd452e4 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 17:40:29 -0400 Subject: [PATCH 21/36] docs(agents): drop removed gf_list_form_feeds from response shapes [ci skip] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gf_list_form_feeds was removed (gf_list_feeds with form_id covers it); the Response Shapes section still listed it as a tool. Verified every gf_*/gv_* name in the server instructions, demo, README, AGENTS.md and mcp.json against the 75 registered tools (26 gf_* + 49 live gv_*) — all match. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index db88b7b..84c8ba9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,7 @@ GET/list methods return just the data: { entries: responseData, total_count } // gf_list_entries { entry: responseData } // gf_get_entry { feed: responseData } // gf_get_feed, gf_create_feed, gf_update_feed, gf_patch_feed -{ feeds: responseData } // gf_list_feeds, gf_list_form_feeds +{ feeds: responseData } // gf_list_feeds (pass form_id to scope to one form) ``` Mutation methods return minimal confirmation: From 9dfda15e2baf190c3b8b229bd2bdc0cdb210caa0 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 18:16:00 -0400 Subject: [PATCH 22/36] chore(pkg): publish runtime only; relocate tests; add publint + verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published package was shipping 25 test files and 5 dev scripts: the `files` allowlist listed `src/` and `scripts/` wholesale, and with a `files` field present npm ignores `.npmignore` entirely — so its test/dev excludes silently did nothing. Packaging (industry-standard allowlist, no .npmignore): - Relocate tests src/tests/ -> top-level test/ so the `src` allowlist entry no longer pulls them in. Rewrite their `../` imports to `../src/` and fix bug-fixes.test.js path anchors (srcDir/projectDir) for the new depth. - Tighten `files` to a precise allowlist (src, mcp.json, .env.example, README, LICENSE, CLAUDE.md, AGENTS.md) and delete the dead `.npmignore`. Tarball: 61 -> 31 files, 0 tests, 0 dev scripts. - Add publint (npm run lint:package) and a prepublishOnly gate that runs the offline suites + publint before publish (omits the live integration test so publishing never hits a real site). Dev tooling: - Add scripts/verify-tool-names.mjs: cross-checks every gf_/gv_ name in the server instructions, docs and demo against the tools the server actually registers (gv_* are generated from the live abilities catalog and can drift). Run via npm run verify:tool-names against a live site. - Document packaging and the verifier in AGENTS.md. Verified: full test:all incl. live integration green; npm pack --dry-run clean (31 files); publint passes; verify:tool-names passes. --- .npmignore | 37 ------ AGENTS.md | 54 ++++----- package-lock.json | 75 ++++++++++++ package.json | 44 +++---- scripts/verify-tool-names.mjs | 107 ++++++++++++++++++ {src/tests => test}/abilities-loader.test.js | 2 +- {src/tests => test}/authentication.test.js | 2 +- {src/tests => test}/bug-fixes.test.js | 22 ++-- .../tests => test}/checkbox-expansion.test.js | 2 +- {src/tests => test}/compact.test.js | 2 +- {src/tests => test}/entries.test.js | 2 +- {src/tests => test}/feeds.test.js | 2 +- .../tests => test}/field-dependencies.test.js | 2 +- {src/tests => test}/field-manager.test.js | 2 +- .../field-operations-e2e.test.js | 8 +- .../field-operations-integration.test.js | 10 +- {src/tests => test}/field-positioner.test.js | 2 +- {src/tests => test}/field-registry.test.js | 2 +- {src/tests => test}/field-validation.test.js | 4 +- {src/tests => test}/forms.test.js | 2 +- {src/tests => test}/helpers.js | 0 {src/tests => test}/integration.test.js | 4 +- {src/tests => test}/mutex.test.js | 2 +- {src/tests => test}/run.js | 0 {src/tests => test}/sanitize.test.js | 2 +- {src/tests => test}/server-tools.test.js | 0 {src/tests => test}/submissions.test.js | 2 +- {src/tests => test}/validation.test.js | 2 +- {src/tests => test}/views-stress.test.js | 10 +- {src/tests => test}/views.test.js | 4 +- 30 files changed, 280 insertions(+), 129 deletions(-) delete mode 100644 .npmignore create mode 100644 scripts/verify-tool-names.mjs rename {src/tests => test}/abilities-loader.test.js (99%) rename {src/tests => test}/authentication.test.js (99%) rename {src/tests => test}/bug-fixes.test.js (94%) rename {src/tests => test}/checkbox-expansion.test.js (99%) rename {src/tests => test}/compact.test.js (99%) rename {src/tests => test}/entries.test.js (99%) rename {src/tests => test}/feeds.test.js (99%) rename {src/tests => test}/field-dependencies.test.js (99%) rename {src/tests => test}/field-manager.test.js (99%) rename {src/tests => test}/field-operations-e2e.test.js (98%) rename {src/tests => test}/field-operations-integration.test.js (96%) rename {src/tests => test}/field-positioner.test.js (99%) rename {src/tests => test}/field-registry.test.js (99%) rename {src/tests => test}/field-validation.test.js (99%) rename {src/tests => test}/forms.test.js (99%) rename {src/tests => test}/helpers.js (100%) rename {src/tests => test}/integration.test.js (99%) rename {src/tests => test}/mutex.test.js (99%) rename {src/tests => test}/run.js (100%) rename {src/tests => test}/sanitize.test.js (99%) rename {src/tests => test}/server-tools.test.js (100%) rename {src/tests => test}/submissions.test.js (99%) rename {src/tests => test}/validation.test.js (99%) rename {src/tests => test}/views-stress.test.js (99%) rename {src/tests => test}/views.test.js (98%) diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 57e9ce5..0000000 --- a/.npmignore +++ /dev/null @@ -1,37 +0,0 @@ -# Source control -.git/ -.gitignore - -# Testing -src/tests/ -test-results/ -test-artifacts/ -coverage/ -*.test.js - -# Development -.env* -scripts/setup-test-data.js -tmp/ - -# IDE and editor files -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Logs -*.log -logs/ - -# MacOS -.DS_Store - -# Documentation (keep only essential docs) -COMPREHENSIVE_PLAN.md -AGENTS.md -CLAUDE.md - -# Development dependencies -node_modules/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 84c8ba9..85b1056 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,36 +38,25 @@ MCP/ │ │ ├── validators.js # Domain-specific validators (forms, entries, feeds, etc.) │ │ ├── field-validation.js # FieldAwareValidator for field-specific rules │ │ └── test-config.js # Dual test/live environment config, TestFormManager -│ ├── utils/ -│ │ ├── compact.js # stripEmpty() — recursive null/empty/false stripping for token optimization -│ │ ├── logger.js # MCP-safe logger (stderr in MCP mode, console in test) -│ │ └── sanitize.js # Credential masking for safe logging -│ └── tests/ -│ ├── run.js # Test runner -│ ├── helpers.js # Mock data generators, test utilities -│ ├── integration.test.js # Live API integration tests -│ ├── server-tools.test.js # Tool registration validation -│ ├── forms.test.js # Forms endpoint tests -│ ├── entries.test.js # Entries endpoint tests -│ ├── feeds.test.js # Feeds endpoint tests -│ ├── submissions.test.js # Submission pipeline tests -│ ├── authentication.test.js # Auth method tests -│ ├── validation.test.js # Input validation tests -│ ├── field-validation.test.js # Field-specific validation -│ ├── field-manager.test.js # FieldManager unit tests -│ ├── field-dependencies.test.js # DependencyTracker tests -│ ├── field-positioner.test.js # PositionEngine tests -│ ├── field-registry.test.js # Field registry tests -│ ├── field-operations-e2e.test.js # Field operations E2E -│ ├── field-operations-integration.test.js # Field ops integration -│ ├── compact.test.js # stripEmpty compact utility tests -│ └── sanitize.test.js # Sanitization tests +│ └── utils/ +│ ├── compact.js # stripEmpty() — recursive null/empty/false stripping for token optimization +│ ├── logger.js # MCP-safe logger (stderr in MCP mode, console in test) +│ └── sanitize.js # Credential masking for safe logging +├── test/ # Test suites — top-level, NOT published (see Packaging) +│ ├── run.js # Custom test runner (npm run test:unit) +│ ├── helpers.js # Mock data generators, test utilities +│ ├── integration.test.js # Live API integration tests (npm test) +│ ├── views.test.js, views-stress.test.js # GravityView inspector + abilities coverage +│ ├── abilities-loader.test.js # Abilities catalog → gv_* tool generation +│ └── *.test.js # forms, entries, feeds, fields, validation, submissions, compact, sanitize, … ├── scripts/ │ ├── check-env.js # Environment validation script │ ├── setup-test-data.js # Test data seeding │ ├── test-field-ops.js # Field operations smoke test │ ├── test-server-output.js # Server output verification -│ └── verify-field-tools.js # Field tool registration check +│ ├── verify-field-tools.js # Field tool registration check +│ ├── verify-tool-names.mjs # Cross-check doc/instruction tool names vs registered tools (dev-only, not published) +│ └── stress-abilities.mjs # Synthetic abilities-loader stress/contract test └── .github/workflows/ ├── publish.yml # npm publish workflow ├── security.yml # Security scanning @@ -245,7 +234,7 @@ All delete operations (`deleteForm`, `deleteEntry`, `deleteFeed`) check `this.al return wrapHandler(() => gravityFormsClient.newToolMethod(params))(); ``` -5. **Add tests** — create test in `src/tests/` following existing patterns (see `forms.test.js` for reference). +5. **Add tests** — create test in `test/` following existing patterns (see `forms.test.js` for reference). Import source under test as `../src/…`. ### Adding a New Field Type to the Registry @@ -340,7 +329,7 @@ npm run test:all # Run everything sequentially npm test # Integration tests (requires live API) ``` -Tests use a custom runner (`src/tests/run.js`), not Jest/Mocha. Test helpers in `src/tests/helpers.js` provide mock data generators (`generateMockForm`, `generateMockEntry`, `generateMockFeed`). +Tests use a custom runner (`test/run.js`), not Jest/Mocha. Test helpers in `test/helpers.js` provide mock data generators (`generateMockForm`, `generateMockEntry`, `generateMockFeed`). For integration tests, set `GRAVITY_FORMS_TEST_*` env vars pointing to a test WordPress site. Test forms are prefixed with `TEST_` and auto-cleaned via `TestFormManager`. @@ -374,6 +363,15 @@ No build step — pure ESM JavaScript, runs directly with `node src/index.js`. R 12. **Test mode resolves env vars at client construction.** When `GRAVITYKIT_MCP_TEST_MODE=true` (or legacy `GRAVITYMCP_TEST_MODE=true`), `testConfig.resolveEnv()` remaps `GRAVITY_FORMS_TEST_BASE_URL` → `GRAVITY_FORMS_BASE_URL` (and consumer key/secret). The rest of the client and AuthManager work unchanged. — `config/test-config.js:60-95`, `gravity-forms-client.js:16` +## Packaging + +What ships to npm is governed solely by the **`files` allowlist** in `package.json` — there is intentionally **no `.npmignore`** (with a `files` field present npm ignores it, so keeping one is misleading). Allowlist, not denylist: a new file ships only if it matches `files`. + +- **Ships:** `src/` (runtime), `mcp.json`, `.env.example`, `README.md`, `LICENSE`, `CLAUDE.md`, `AGENTS.md`. +- **Excluded by omission:** `test/` (tests are top-level, not under `src/`), `scripts/` (dev tooling), `.github/`, `package-lock.json`. +- **`npm run lint:package`** runs [publint](https://publint.dev) to validate package correctness; **`prepublishOnly`** runs the offline test suites + publint, so a broken or mis-packaged build can't be published. It deliberately omits the live integration test (`npm test`) to avoid hitting a real site during publish. +- **Verify before publishing:** `npm pack --dry-run` lists exactly what will ship. + ## Releasing **Every version tag MUST include a CHANGELOG.md update.** Follow this checklist: @@ -388,6 +386,8 @@ No build step — pure ESM JavaScript, runs directly with `node src/index.js`. R Skipping any step (especially CHANGELOG) will leave the release history incomplete for future developers and AI agents. +**Before tagging, run `npm run verify:tool-names` against a live site.** The `gv_*` tools are generated from the installed GravityView/Foundation Abilities catalog, so a catalog rename can silently leave the server `instructions` string, README, or the demo referencing tools that no longer exist. The script cross-checks every `gf_`/`gv_` name in prose against what the server actually registers and exits non-zero on a mismatch. Requires WordPress credentials in the environment (see Required Environment). Dev-only — not shipped in the npm package. + ## Related Resources - **CLAUDE.md** — Concise project identity and critical rules diff --git a/package-lock.json b/package-lock.json index f7d054b..47d0c09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,9 @@ "bin": { "gkmcp": "src/index.js" }, + "devDependencies": { + "publint": "^0.3.21" + }, "engines": { "node": ">=18.0.0" } @@ -72,6 +75,19 @@ } } }, + "node_modules/@publint/pack": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.4.tgz", + "integrity": "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -866,6 +882,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -923,6 +949,13 @@ "wrappy": "1" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "dev": true, + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -951,6 +984,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/pkce-challenge": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", @@ -979,6 +1019,28 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/publint": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.21.tgz", + "integrity": "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@publint/pack": "^0.1.4", + "package-manager-detector": "^1.6.0", + "picocolors": "^1.1.1", + "sade": "^1.8.1" + }, + "bin": { + "publint": "src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -1043,6 +1105,19 @@ "node": ">= 18" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", diff --git a/package.json b/package.json index dbcdb0b..6906c3e 100644 --- a/package.json +++ b/package.json @@ -13,21 +13,24 @@ "inspect": "npx @modelcontextprotocol/inspector node src/index.js", "check-env": "node scripts/check-env.js", "setup-test-data": "node scripts/setup-test-data.js", - "test": "node src/tests/integration.test.js", - "test:unit": "node src/tests/run.js", - "test:field-ops": "node --test src/tests/field-manager.test.js src/tests/field-registry.test.js src/tests/field-dependencies.test.js src/tests/field-positioner.test.js", - "test:auth": "node src/tests/authentication.test.js", - "test:forms": "node src/tests/forms.test.js", - "test:entries": "node src/tests/entries.test.js", - "test:feeds": "node src/tests/feeds.test.js", - "test:submissions": "node src/tests/submissions.test.js", - "test:validation": "node src/tests/validation.test.js", - "test:field-validation": "node src/tests/field-validation.test.js", - "test:tools": "node src/tests/server-tools.test.js", - "test:compact": "node src/tests/compact.test.js", - "test:views": "node src/tests/views.test.js", + "verify:tool-names": "node scripts/verify-tool-names.mjs", + "test": "node test/integration.test.js", + "test:unit": "node test/run.js", + "test:field-ops": "node --test test/field-manager.test.js test/field-registry.test.js test/field-dependencies.test.js test/field-positioner.test.js", + "test:auth": "node test/authentication.test.js", + "test:forms": "node test/forms.test.js", + "test:entries": "node test/entries.test.js", + "test:feeds": "node test/feeds.test.js", + "test:submissions": "node test/submissions.test.js", + "test:validation": "node test/validation.test.js", + "test:field-validation": "node test/field-validation.test.js", + "test:tools": "node test/server-tools.test.js", + "test:compact": "node test/compact.test.js", + "test:views": "node test/views.test.js", "test:all": "npm run test:unit && npm run test:field-ops && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm run test:views && npm test", - "test:coverage": "echo 'Running all tests with coverage analysis' && npm run test:all" + "test:coverage": "echo 'Running all tests with coverage analysis' && npm run test:all", + "lint:package": "publint", + "prepublishOnly": "npm run test:unit && npm run test:field-ops && npm run test:field-validation && npm run test:views && publint" }, "keywords": [ "mcp", @@ -51,12 +54,12 @@ }, "files": [ "src/", - "scripts/", - "LICENSE", - "README.md", - ".env.example", "mcp.json", - "CLAUDE.md" + ".env.example", + "README.md", + "LICENSE", + "CLAUDE.md", + "AGENTS.md" ], "repository": { "type": "git", @@ -95,5 +98,8 @@ "Integration testing", "Security validation" ] + }, + "devDependencies": { + "publint": "^0.3.21" } } diff --git a/scripts/verify-tool-names.mjs b/scripts/verify-tool-names.mjs new file mode 100644 index 0000000..2f0e3b3 --- /dev/null +++ b/scripts/verify-tool-names.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Verify that every gf_ / gv_ tool name referenced in prose (server + * instructions, docs, the demo) matches a tool the server actually + * registers. The gv_* surface is generated at runtime from the installed + * GravityView/Foundation Abilities catalog, so a catalog rename can silently + * leave the instructions string or docs pointing at tools that no longer + * exist — exactly the drift that broke demo-abilities.mjs. + * + * Authoritative set: + * - gf_* (static): the `name:` props in src/index.js (GF_TOOL_DEFINITIONS) + * and src/field-operations/index.js (fieldOperationTools) + * - gv_* (dynamic): loaded live from the connected site's catalog + * - gk-gravityview/* abilities: the live catalog (for the demo's references) + * + * Requires a live WordPress connection (same env as the server): + * GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD, + * or the GRAVITY_FORMS_* equivalents (see .env.example / AGENTS.md). + * + * Usage: node scripts/verify-tool-names.mjs (or: npm run verify:tool-names) + * Exit: 0 = all references match · 1 = mismatches found or catalog unreachable + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { WordPressClient } from '../src/wp-client.js'; +import { loadAbilitiesAsTools } from '../src/abilities/loader.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); + +// Tokens that look like tool names in prose but intentionally are not — keep +// this list short and explain each one so it stays honest. +const IGNORE = new Map([ + ['gf_new_tool', 'AGENTS.md "Adding a New Tool" example placeholder'], + ['gv_revision_', 'entry-meta key prefix gv_revision_* (compact-mode docs), not a tool'], +]); + +// --- Authoritative set: exactly what the server registers --- +const grabNames = (rel, re) => [...read(rel).matchAll(re)].map((m) => m[1]); +const gfStatic = new Set([ + ...grabNames('src/index.js', /name:\s*'(gf_[a-z0-9_]+)'/g), + ...grabNames('src/field-operations/index.js', /name:\s*'(gf_[a-z0-9_]+)'/g), +]); + +const wp = new WordPressClient(process.env); +let gvDynamic, abilityNames; +try { + const { definitions } = await loadAbilitiesAsTools(wp); + gvDynamic = new Set(definitions.map((d) => d.name)); + const catalog = (await wp.httpClient.request({ + method: 'GET', + baseURL: wp.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + })).data; + abilityNames = new Set(catalog.filter((a) => a.name?.startsWith('gk-gravityview/')).map((a) => a.name)); +} catch (err) { + console.error(`✗ Could not load the live abilities catalog from ${wp.baseUrl}`); + console.error(` ${err.message}`); + console.error(' Set WP credentials (see AGENTS.md) and point at a running site, then retry.'); + process.exit(1); +} + +const authToolNames = new Set([...gfStatic, ...gvDynamic]); +console.log(`Authoritative: ${gfStatic.size} gf_* + ${gvDynamic.size} gv_* = ${authToolNames.size} tools; ${abilityNames.size} gk-gravityview/* abilities\n`); + +// --- Referenced names per surface --- +const TOOL_RE = /\b(g[fv]_[a-z0-9_]+)\b/g; +const ABIL_RE = /\bgk-gravityview\/[a-z0-9-]+/g; + +// The server `instructions` string is what the agent reads — check that line +// specifically rather than the whole file (which also *defines* the tools). +const instrLine = read('src/index.js').split('\n').find((l) => l.includes('instructions:')) || ''; + +const surfaces = [ + ['src/index.js (instructions string)', instrLine, TOOL_RE], + ['demo-abilities.mjs (tool handlers)', read('demo-abilities.mjs'), TOOL_RE], + ['demo-abilities.mjs (ability names)', read('demo-abilities.mjs'), ABIL_RE], + ['AGENTS.md', read('AGENTS.md'), TOOL_RE], + ['README.md', read('README.md'), TOOL_RE], + ['CLAUDE.md', read('CLAUDE.md'), TOOL_RE], + ['mcp.json', read('mcp.json'), TOOL_RE], +]; + +let problems = 0; +let ignored = 0; +for (const [label, text, re] of surfaces) { + const isAbil = re === ABIL_RE; + const valid = isAbil ? abilityNames : authToolNames; + const ref = [...new Set([...text.matchAll(re)].map((m) => m[0]))].sort(); + const bad = ref.filter((n) => !valid.has(n) && !IGNORE.has(n)); + ignored += ref.filter((n) => IGNORE.has(n)).length; + console.log(`${label}: ${ref.length} referenced, ${bad.length} unknown`); + if (bad.length) { + problems += bad.length; + bad.forEach((n) => console.log(` ✗ ${n}`)); + } +} + +if (ignored) { + console.log(`\nIgnored ${ignored} known non-tool token(s):`); + for (const [tok, why] of IGNORE) console.log(` • ${tok} — ${why}`); +} + +console.log(`\n${problems === 0 ? '✅ All referenced names match registered MCP tool/ability names' : `❌ ${problems} mismatch(es) — update the docs or the IGNORE list`}`); +process.exit(problems === 0 ? 0 : 1); diff --git a/src/tests/abilities-loader.test.js b/test/abilities-loader.test.js similarity index 99% rename from src/tests/abilities-loader.test.js rename to test/abilities-loader.test.js index f1e5f0e..270a55a 100644 --- a/src/tests/abilities-loader.test.js +++ b/test/abilities-loader.test.js @@ -15,7 +15,7 @@ import { methodForAbility, FOUNDATION_CATALOG_ROUTE, CORE_ABILITIES_ROUTE, -} from '../abilities/loader.js'; +} from '../src/abilities/loader.js'; const suite = new TestRunner('Abilities Loader Tests'); diff --git a/src/tests/authentication.test.js b/test/authentication.test.js similarity index 99% rename from src/tests/authentication.test.js rename to test/authentication.test.js index 8521f95..518f6bf 100644 --- a/src/tests/authentication.test.js +++ b/test/authentication.test.js @@ -3,7 +3,7 @@ * Tests Basic Auth (primary) and OAuth 1.0a (secondary) authentication methods */ -import { AuthManager, BasicAuthHandler, OAuth1Handler, validateRestApiAccess } from '../config/auth.js'; +import { AuthManager, BasicAuthHandler, OAuth1Handler, validateRestApiAccess } from '../src/config/auth.js'; import { TestRunner, TestAssert, MockHttpClient, MockResponse, setupTestEnvironment } from './helpers.js'; const suite = new TestRunner('Authentication Tests'); diff --git a/src/tests/bug-fixes.test.js b/test/bug-fixes.test.js similarity index 94% rename from src/tests/bug-fixes.test.js rename to test/bug-fixes.test.js index da30e13..3d7208b 100644 --- a/src/tests/bug-fixes.test.js +++ b/test/bug-fixes.test.js @@ -12,8 +12,8 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const srcDir = join(__dirname, '..'); -const projectDir = join(__dirname, '..', '..'); +const srcDir = join(__dirname, '..', 'src'); +const projectDir = join(__dirname, '..'); const suite = new TestRunner('Bug Fix Regression Tests'); @@ -200,7 +200,7 @@ suite.test('Bug #5: Delete tools have destructiveHint: true', () => { // ================================= suite.test('Bug #7: validateField strips _variant and _meta from output', async () => { - const { FieldAwareValidator } = await import('../config/field-validation.js'); + const { FieldAwareValidator } = await import('../src/config/field-validation.js'); const field = { id: 1, @@ -215,7 +215,7 @@ suite.test('Bug #7: validateField strips _variant and _meta from output', async }); suite.test('Bug #7: validateFormFields strips _variant and _meta from all fields', async () => { - const { FieldAwareValidator } = await import('../config/field-validation.js'); + const { FieldAwareValidator } = await import('../src/config/field-validation.js'); const fields = [ { id: 1, type: 'text', label: 'Text Field' }, @@ -234,7 +234,7 @@ suite.test('Bug #7: validateFormFields strips _variant and _meta from all fields // ================================= suite.test('Bug #8: gf_add_field throws errors instead of swallowing them', async () => { - const { fieldOperationHandlers } = await import('../field-operations/index.js'); + const { fieldOperationHandlers } = await import('../src/field-operations/index.js'); // Create a mock fieldManager that throws const mockFieldOps = { @@ -261,7 +261,7 @@ suite.test('Bug #8: gf_add_field throws errors instead of swallowing them', asyn }); suite.test('Bug #8: gf_update_field throws errors instead of swallowing them', async () => { - const { fieldOperationHandlers } = await import('../field-operations/index.js'); + const { fieldOperationHandlers } = await import('../src/field-operations/index.js'); const mockFieldOps = { fieldManager: { @@ -283,7 +283,7 @@ suite.test('Bug #8: gf_update_field throws errors instead of swallowing them', a }); suite.test('Bug #8: gf_delete_field throws errors instead of swallowing them', async () => { - const { fieldOperationHandlers } = await import('../field-operations/index.js'); + const { fieldOperationHandlers } = await import('../src/field-operations/index.js'); const mockFieldOps = { fieldManager: { @@ -309,7 +309,7 @@ suite.test('Bug #8: gf_delete_field throws errors instead of swallowing them', a // ================================= suite.test('Bug #9: Name field registry has correct sub-input mapping', async () => { - const { fieldRegistry } = await import('../field-definitions/field-registry.js'); + const { fieldRegistry } = await import('../src/field-definitions/field-registry.js'); const nameField = fieldRegistry.name; TestAssert.exists(nameField, 'Name field should exist in registry'); @@ -325,7 +325,7 @@ suite.test('Bug #9: Name field registry has correct sub-input mapping', async () }); suite.test('Bug #9: generateCompoundInputs matches registry for name field', async () => { - const { generateCompoundInputs } = await import('../field-definitions/field-registry.js'); + const { generateCompoundInputs } = await import('../src/field-definitions/field-registry.js'); const field = { id: 5, type: 'name', nameFormat: 'advanced' }; const inputs = generateCompoundInputs(field); @@ -410,8 +410,8 @@ suite.test('Bug #23: mcp.json version matches package.json version', () => { // ================================= suite.test('Bug #24: Filtering by "conditional" feature returns >0 results', async () => { - const { fieldOperationHandlers } = await import('../field-operations/index.js'); - const fieldRegistry = (await import('../field-definitions/field-registry.js')).default; + const { fieldOperationHandlers } = await import('../src/field-operations/index.js'); + const fieldRegistry = (await import('../src/field-definitions/field-registry.js')).default; const result = await fieldOperationHandlers.gf_list_field_types( { feature: 'conditional' }, diff --git a/src/tests/checkbox-expansion.test.js b/test/checkbox-expansion.test.js similarity index 99% rename from src/tests/checkbox-expansion.test.js rename to test/checkbox-expansion.test.js index 2b6432a..34b0d6e 100644 --- a/src/tests/checkbox-expansion.test.js +++ b/test/checkbox-expansion.test.js @@ -10,7 +10,7 @@ * option/quiz/survey/poll checkbox variants, multiselect, radio fallback. */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/src/tests/compact.test.js b/test/compact.test.js similarity index 99% rename from src/tests/compact.test.js rename to test/compact.test.js index 4ffe396..861d7a1 100644 --- a/src/tests/compact.test.js +++ b/test/compact.test.js @@ -7,7 +7,7 @@ * Preserves: false, 0, "0" */ -import { stripEmpty, stripEntryMeta, stripEntryMetaFromResponse } from '../utils/compact.js'; +import { stripEmpty, stripEntryMeta, stripEntryMetaFromResponse } from '../src/utils/compact.js'; import { TestRunner, TestAssert } from './helpers.js'; const runner = new TestRunner('Compact Utility Tests'); diff --git a/src/tests/entries.test.js b/test/entries.test.js similarity index 99% rename from src/tests/entries.test.js rename to test/entries.test.js index f5bf347..967515d 100644 --- a/src/tests/entries.test.js +++ b/test/entries.test.js @@ -3,7 +3,7 @@ * Tests all 6 entries management tools with comprehensive coverage */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/src/tests/feeds.test.js b/test/feeds.test.js similarity index 99% rename from src/tests/feeds.test.js rename to test/feeds.test.js index f887125..628c0a3 100644 --- a/src/tests/feeds.test.js +++ b/test/feeds.test.js @@ -3,7 +3,7 @@ * Tests all 7 add-on feed management tools */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/src/tests/field-dependencies.test.js b/test/field-dependencies.test.js similarity index 99% rename from src/tests/field-dependencies.test.js rename to test/field-dependencies.test.js index 892ebde..275ca59 100644 --- a/src/tests/field-dependencies.test.js +++ b/test/field-dependencies.test.js @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert'; -import { DependencyTracker } from '../field-operations/field-dependencies.js'; +import { DependencyTracker } from '../src/field-operations/field-dependencies.js'; // Sample form with various dependencies const createTestForm = () => ({ diff --git a/src/tests/field-manager.test.js b/test/field-manager.test.js similarity index 99% rename from src/tests/field-manager.test.js rename to test/field-manager.test.js index 1447446..8b14c97 100644 --- a/src/tests/field-manager.test.js +++ b/test/field-manager.test.js @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert'; -import { FieldManager } from '../field-operations/field-manager.js'; +import { FieldManager } from '../src/field-operations/field-manager.js'; // Mock dependencies. Mirrors the GravityFormsClient contract FieldManager // actually consumes: getForm() resolves { form } and replaceForm() does a diff --git a/src/tests/field-operations-e2e.test.js b/test/field-operations-e2e.test.js similarity index 98% rename from src/tests/field-operations-e2e.test.js rename to test/field-operations-e2e.test.js index 913ede4..c23850c 100644 --- a/src/tests/field-operations-e2e.test.js +++ b/test/field-operations-e2e.test.js @@ -3,10 +3,10 @@ * Tests real-world scenarios from user perspective */ -import { fieldOperationHandlers } from '../field-operations/index.js'; -import { testConfig, TestFormManager } from '../config/test-config.js'; -import GravityFormsClient from '../gravity-forms-client.js'; -import FieldAwareValidator from '../config/field-validation.js'; +import { fieldOperationHandlers } from '../src/field-operations/index.js'; +import { testConfig, TestFormManager } from '../src/config/test-config.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; +import FieldAwareValidator from '../src/config/field-validation.js'; console.log('🎭 Field Operations E2E Test Scenarios\n'); diff --git a/src/tests/field-operations-integration.test.js b/test/field-operations-integration.test.js similarity index 96% rename from src/tests/field-operations-integration.test.js rename to test/field-operations-integration.test.js index f207f62..2148c5a 100644 --- a/src/tests/field-operations-integration.test.js +++ b/test/field-operations-integration.test.js @@ -3,11 +3,11 @@ * Tests the complete flow from MCP tool calls to API interactions */ -import { fieldOperationHandlers } from '../field-operations/index.js'; -import { testConfig, TestFormManager } from '../config/test-config.js'; -import GravityFormsClient from '../gravity-forms-client.js'; -import fieldRegistry from '../field-definitions/field-registry.js'; -import FieldAwareValidator from '../config/field-validation.js'; +import { fieldOperationHandlers } from '../src/field-operations/index.js'; +import { testConfig, TestFormManager } from '../src/config/test-config.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; +import fieldRegistry from '../src/field-definitions/field-registry.js'; +import FieldAwareValidator from '../src/config/field-validation.js'; console.log('🧪 Field Operations Integration Tests\n'); diff --git a/src/tests/field-positioner.test.js b/test/field-positioner.test.js similarity index 99% rename from src/tests/field-positioner.test.js rename to test/field-positioner.test.js index acb80c2..5153bef 100644 --- a/src/tests/field-positioner.test.js +++ b/test/field-positioner.test.js @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert'; -import { PositionEngine } from '../field-operations/field-positioner.js'; +import { PositionEngine } from '../src/field-operations/field-positioner.js'; // Create test fields with page breaks const createTestFields = () => [ diff --git a/src/tests/field-registry.test.js b/test/field-registry.test.js similarity index 99% rename from src/tests/field-registry.test.js rename to test/field-registry.test.js index 980d697..5efbad5 100644 --- a/src/tests/field-registry.test.js +++ b/test/field-registry.test.js @@ -4,7 +4,7 @@ import test from 'node:test'; import assert from 'node:assert'; -import { generateCompoundInputs, isCompoundField, getFieldDefinition } from '../field-definitions/field-registry.js'; +import { generateCompoundInputs, isCompoundField, getFieldDefinition } from '../src/field-definitions/field-registry.js'; test('generateCompoundInputs - address field', async (t) => { await t.test('generates US address inputs', () => { diff --git a/src/tests/field-validation.test.js b/test/field-validation.test.js similarity index 99% rename from src/tests/field-validation.test.js rename to test/field-validation.test.js index 68b5e2b..3105dc6 100644 --- a/src/tests/field-validation.test.js +++ b/test/field-validation.test.js @@ -5,14 +5,14 @@ * the field registry to ensure 100% valid structure. */ -import { FieldAwareValidator } from '../config/field-validation.js'; +import { FieldAwareValidator } from '../src/config/field-validation.js'; import { fieldRegistry, getFieldDefinition, isCompoundField, isArrayField, detectFieldVariant -} from '../field-definitions/field-registry.js'; +} from '../src/field-definitions/field-registry.js'; // Test runner let passedTests = 0; diff --git a/src/tests/forms.test.js b/test/forms.test.js similarity index 99% rename from src/tests/forms.test.js rename to test/forms.test.js index a5550f7..d35c447 100644 --- a/src/tests/forms.test.js +++ b/test/forms.test.js @@ -3,7 +3,7 @@ * Tests all 6 forms management tools with happy path, edge cases, and failure modes */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/src/tests/helpers.js b/test/helpers.js similarity index 100% rename from src/tests/helpers.js rename to test/helpers.js diff --git a/src/tests/integration.test.js b/test/integration.test.js similarity index 99% rename from src/tests/integration.test.js rename to test/integration.test.js index 355a090..d66c149 100644 --- a/src/tests/integration.test.js +++ b/test/integration.test.js @@ -4,9 +4,9 @@ */ import dotenv from 'dotenv'; -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert } from './helpers.js'; -import { validateRestApiAccess } from '../config/auth.js'; +import { validateRestApiAccess } from '../src/config/auth.js'; // Set test mode to suppress initialization messages to stderr process.env.GRAVITY_FORMS_TEST_MODE = 'true'; diff --git a/src/tests/mutex.test.js b/test/mutex.test.js similarity index 99% rename from src/tests/mutex.test.js rename to test/mutex.test.js index 8cc68ce..a9a6c99 100644 --- a/src/tests/mutex.test.js +++ b/test/mutex.test.js @@ -9,7 +9,7 @@ */ import { TestRunner, TestAssert, wait } from './helpers.js'; -import ResourceMutex, { resourceMutex } from '../utils/mutex.js'; +import ResourceMutex, { resourceMutex } from '../src/utils/mutex.js'; const suite = new TestRunner('Mutex & Concurrency Tests'); diff --git a/src/tests/run.js b/test/run.js similarity index 100% rename from src/tests/run.js rename to test/run.js diff --git a/src/tests/sanitize.test.js b/test/sanitize.test.js similarity index 99% rename from src/tests/sanitize.test.js rename to test/sanitize.test.js index 0df299b..83d5b38 100644 --- a/src/tests/sanitize.test.js +++ b/test/sanitize.test.js @@ -4,7 +4,7 @@ */ import { TestRunner, TestAssert } from './helpers.js'; -import { sanitize, sanitizeUrl, sanitizeHeaders } from '../utils/sanitize.js'; +import { sanitize, sanitizeUrl, sanitizeHeaders } from '../src/utils/sanitize.js'; const suite = new TestRunner('Sanitization Tests'); diff --git a/src/tests/server-tools.test.js b/test/server-tools.test.js similarity index 100% rename from src/tests/server-tools.test.js rename to test/server-tools.test.js diff --git a/src/tests/submissions.test.js b/test/submissions.test.js similarity index 99% rename from src/tests/submissions.test.js rename to test/submissions.test.js index 9b1c125..42f80f3 100644 --- a/src/tests/submissions.test.js +++ b/test/submissions.test.js @@ -3,7 +3,7 @@ * Tests form submission workflow, validation, and notifications */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/src/tests/validation.test.js b/test/validation.test.js similarity index 99% rename from src/tests/validation.test.js rename to test/validation.test.js index 628f6c4..4d0a713 100644 --- a/src/tests/validation.test.js +++ b/test/validation.test.js @@ -3,7 +3,7 @@ * Tests input validation for all tools and edge cases */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/src/tests/views-stress.test.js b/test/views-stress.test.js similarity index 99% rename from src/tests/views-stress.test.js rename to test/views-stress.test.js index 0195207..20ba3b9 100644 --- a/src/tests/views-stress.test.js +++ b/test/views-stress.test.js @@ -57,10 +57,10 @@ import dotenv from 'dotenv'; import fs from 'node:fs'; import path from 'node:path'; -import GravityFormsClient from '../gravity-forms-client.js'; -import { GravityViewInspectorClient } from '../gravityview/inspector-client.js'; -import { ViewValidator } from '../gravityview/view-validator.js'; -import { loadAbilitiesAsTools } from '../abilities/loader.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; +import { GravityViewInspectorClient } from '../src/gravityview/inspector-client.js'; +import { ViewValidator } from '../src/gravityview/view-validator.js'; +import { loadAbilitiesAsTools } from '../src/abilities/loader.js'; import { TestRunner, TestAssert } from './helpers.js'; dotenv.config(); @@ -2641,7 +2641,7 @@ suite.test('Safety: abilities-loader does NOT add a client-side destructive gate // ability registry, so the env-var ratchet served no remaining // purpose. Status-level removal still flows through gv_view_status_set // and is gated server-side by the WP `delete_post` capability. - const { loadAbilitiesAsTools } = await import('../abilities/loader.js'); + const { loadAbilitiesAsTools } = await import('../src/abilities/loader.js'); const { handlers } = await loadAbilitiesAsTools(gvClient); let msg = ''; try { diff --git a/src/tests/views.test.js b/test/views.test.js similarity index 98% rename from src/tests/views.test.js rename to test/views.test.js index 27ddce0..8e1a6f8 100644 --- a/src/tests/views.test.js +++ b/test/views.test.js @@ -7,8 +7,8 @@ * mode replace/merge, area-key URL encoding). */ -import { GravityViewInspectorClient } from '../gravityview/inspector-client.js'; -import { ViewValidator } from '../gravityview/view-validator.js'; +import { GravityViewInspectorClient } from '../src/gravityview/inspector-client.js'; +import { ViewValidator } from '../src/gravityview/view-validator.js'; import { TestRunner, TestAssert, From c85d2d93355c9179651e806e4ffe27b0e23fbc22 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 18:55:44 -0400 Subject: [PATCH 23/36] docs: CLAUDE.md re-exports AGENTS.md; AGENTS.md is the single source [ci skip] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md is now a one-line `@AGENTS.md` import. Moved the only unique CLAUDE.md content — the Project Identity block (package + version) — into AGENTS.md, retargeted the release checklist's version-bump step to AGENTS.md, added AGENTS.md to the repo map, and dropped CLAUDE.md from the verify-tool-names surfaces (it re-exports AGENTS.md, already checked). Everything else (commands, env, critical rules, release steps) was already covered in AGENTS.md. --- AGENTS.md | 16 ++++++++--- CLAUDE.md | 51 +---------------------------------- scripts/verify-tool-names.mjs | 2 +- 3 files changed, 15 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 85b1056..0d076a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,15 @@ > MCP server providing 26 tools for full Gravity Forms REST API v2 coverage, enabling AI agents to manage forms, entries, feeds, notifications, and fields programmatically. +This is the single canonical doc for the project (agents and humans). `CLAUDE.md` simply re-exports it via `@AGENTS.md`. + +## Project Identity + +- **Package:** `@gravitykit/mcp` v2.1.0 +- **Type:** Node.js MCP server (ESM) +- **Purpose:** Full Gravity Forms REST API v2 coverage via 26 MCP tools +- **Repo:** https://github.com/GravityKit/MCP + ## Quick Start **What this is:** A Node.js MCP (Model Context Protocol) server that wraps the Gravity Forms REST API v2. It authenticates via Basic Auth (preferred) or OAuth 1.0a and exposes 26 tools for CRUD operations on forms, entries, feeds, notifications, field filters, results, and intelligent field management. @@ -17,7 +26,8 @@ MCP/ ├── package.json # @gravitykit/mcp, ESM, npm scripts ├── mcp.json # MCP manifest (tool catalog, auth config) ├── .env.example # All env vars documented -├── CLAUDE.md # Project docs for AI context +├── AGENTS.md # Canonical agent + developer docs (this file) +├── CLAUDE.md # Claude Code entry point — re-exports AGENTS.md (@AGENTS.md) ├── src/ │ ├── index.js # Server bootstrap, tool registration, handler routing │ ├── gravity-forms-client.js # GravityFormsClient: HTTP client, all API methods @@ -378,7 +388,7 @@ What ships to npm is governed solely by the **`files` allowlist** in `package.js 1. **Update `CHANGELOG.md`** — add a new `## [X.Y.Z] - YYYY-MM-DD` section with all changes since the last release. Follow [Keep a Changelog](https://keepachangelog.com/) format (Added, Changed, Fixed, Removed). 2. **Bump `version` in `package.json`** -3. **Update version in `CLAUDE.md`** (Project Identity → Package line) +3. **Update version in `AGENTS.md`** (Project Identity → Package line) 4. **Add link** at bottom of `CHANGELOG.md`: `[X.Y.Z]: https://github.com/GravityKit/MCP/releases/tag/vX.Y.Z` 5. **Commit**: `git commit -m "chore(release): bump version to X.Y.Z"` 6. **Tag**: `git tag vX.Y.Z` @@ -390,7 +400,7 @@ Skipping any step (especially CHANGELOG) will leave the release history incomple ## Related Resources -- **CLAUDE.md** — Concise project identity and critical rules +- **CLAUDE.md** — Claude Code entry point; re-exports this file via `@AGENTS.md` - **README.md** — User-facing setup and usage guide - **.env.example** — Complete environment variable reference - **mcp.json** — MCP manifest (tool catalog, auth requirements) diff --git a/CLAUDE.md b/CLAUDE.md index a6c33ac..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,50 +1 @@ -You MUST fully ingest @AGENTS.md first. - -# GravityKit MCP Server - -## Project Identity - -- **Package:** `@gravitykit/mcp` v2.1.0 -- **Type:** Node.js MCP server (ESM) -- **Purpose:** Full Gravity Forms REST API v2 coverage via 26 MCP tools -- **Repo:** https://github.com/GravityKit/MCP - -## Key Commands - -```bash -npm run dev # Dev with auto-reload -npm run inspect # MCP Inspector debugging -npm run check-env # Validate environment -npm run test:all # Run all test suites -npm test # Integration tests (live API) -``` - -## Environment - -Required env vars (see `.env.example` for full list): -- `GRAVITY_FORMS_CONSUMER_KEY` — from WP Admin > Forms > Settings > REST API -- `GRAVITY_FORMS_CONSUMER_SECRET` -- `GRAVITY_FORMS_BASE_URL` — WordPress site URL, no trailing slash - -## Critical Rules - -1. **Never use `console.log` in MCP mode** — stdout is JSON-RPC. Use `logger.info/error/warn` from `utils/logger.js` -2. **Always use `.js` extension** in imports (ESM requirement) -3. **Delete operations require `GRAVITY_FORMS_ALLOW_DELETE=true`** env var -4. **Fields are form properties** — no direct field endpoints; modify via form PUT -5. **Update operations fetch-then-merge** — always GET existing data first to avoid data loss -6. **Minimize response tokens** — no pretty-print (`JSON.stringify(result)` not `null, 2`), no redundant `message` strings, no echo-back of input IDs, no `created`/`updated` booleans. Return only essential data. -7. **Keep tool descriptions terse** — every token in tool schemas is sent on every `tools/list` call -8. **Compact mode strips null, empty strings, and entry meta** — `stripEmpty()` in `utils/compact.js` runs on all responses. Entry tools also strip plugin-added meta keys via `stripEntryMeta()`, keeping only core properties and field values. `false` is preserved. Pass `compact=false` for full raw data. -9. **Test mode uses dev site** — when `GRAVITYKIT_MCP_TEST_MODE=true` (or legacy `GRAVITYMCP_TEST_MODE=true`), the client auto-resolves `GRAVITY_FORMS_TEST_*` env vars to connect to the test site instead of production. Resolution logic lives in `testConfig.resolveEnv()` in `config/test-config.js`. - -## Release Checklist - -When tagging a new version, you MUST complete ALL of these steps: - -1. Update `CHANGELOG.md` with all changes since the last release (follow Keep a Changelog format) -2. Bump `version` in `package.json` -3. Update the version in this file (`CLAUDE.md` → Project Identity → Package) -4. Commit with message `chore(release): bump version to X.Y.Z` -5. Tag: `git tag vX.Y.Z` -6. Push: `git push origin main --tags` +@AGENTS.md diff --git a/scripts/verify-tool-names.mjs b/scripts/verify-tool-names.mjs index 2f0e3b3..6616b17 100644 --- a/scripts/verify-tool-names.mjs +++ b/scripts/verify-tool-names.mjs @@ -79,8 +79,8 @@ const surfaces = [ ['demo-abilities.mjs (ability names)', read('demo-abilities.mjs'), ABIL_RE], ['AGENTS.md', read('AGENTS.md'), TOOL_RE], ['README.md', read('README.md'), TOOL_RE], - ['CLAUDE.md', read('CLAUDE.md'), TOOL_RE], ['mcp.json', read('mcp.json'), TOOL_RE], + // CLAUDE.md re-exports AGENTS.md (@AGENTS.md) — already covered above. ]; let problems = 0; From 3f3ad9bcb6fbdcee8f2fe6963d69ae6a89006c67 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 19:21:09 -0400 Subject: [PATCH 24/36] =?UTF-8?q?refactor(abilities):=20rename=20gv=5Frelo?= =?UTF-8?q?ad=5Fabilities=20=E2=86=92=20gk=5Freload=5Fabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reload tool operates on the whole GravityKit abilities plane (the product-agnostic Foundation catalog), not GravityView specifically — `gv_` is GravityView's product prefix. Rename the built-in to `gk_` so the GravityKit-wide control tool is distinct from the `gv_*` product tools. Also surface it in the server instructions so an agent knows how to (re)load the catalog when gv_* tools are missing. Pre-release; no public API impact. --- src/index.js | 20 ++++++++++---------- test/abilities-loader.test.js | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/index.js b/src/index.js index 9287516..4d508f8 100644 --- a/src/index.js +++ b/src/index.js @@ -44,7 +44,7 @@ const server = new Server( capabilities: { tools: { listChanged: true } }, - instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields — works on any Gravity Forms site) and product tools (gv_* for GravityView Views, plus other GravityKit products) generated from the site\'s GravityKit Foundation abilities catalog — these appear only when Foundation is active on the connected site.\n\nGravityView authoring flow: 1) gv_view_create to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_grid_row_add (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_view_config_apply for bulk one-shot writes, or gv_view_field_add / gv_view_field_patch / gv_view_field_move for surgical edits. 4) For Search Bar internal layout, use gv_search_field_add / gv_search_field_patch / gv_search_field_remove — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_layouts_list (Layout Builder, DIY, Table, List, DataTables, Map — with is_grid_aware flag), gv_widgets_list, gv_grid_row_types_list, gv_widget_zones_list (header/footer), gv_search_zones_list (search-general/search-advanced), gv_available_fields_get. Schema: gv_field_type_schema_get works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_view_field_move accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' + instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields — works on any Gravity Forms site) and product tools (gv_* for GravityView Views, plus other GravityKit products) generated from the site\'s GravityKit Foundation abilities catalog — these appear only when Foundation is active on the connected site; call gk_reload_abilities to (re)load the catalog if gv_* tools are missing or stale.\n\nGravityView authoring flow: 1) gv_view_create to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_grid_row_add (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_view_config_apply for bulk one-shot writes, or gv_view_field_add / gv_view_field_patch / gv_view_field_move for surgical edits. 4) For Search Bar internal layout, use gv_search_field_add / gv_search_field_patch / gv_search_field_remove — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_layouts_list (Layout Builder, DIY, Table, List, DataTables, Map — with is_grid_aware flag), gv_widgets_list, gv_grid_row_types_list, gv_widget_zones_list (header/footer), gv_search_zones_list (search-general/search-advanced), gv_available_fields_get. Schema: gv_field_type_schema_get works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_view_field_move accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' } ); @@ -65,7 +65,7 @@ let abilityToolHandlers = null; // covers transient cert / network / WP-not-yet-booted failures without // requiring an MCP process restart. Retries are bounded by a cooldown // so Foundation-less sites (gf_* only) don't pay two failed requests -// on every tools/list forever; gv_reload_abilities bypasses it. +// on every tools/list forever; gk_reload_abilities bypasses it. let abilitiesLoadPromise = null; let abilitiesFailedAt = 0; const ABILITIES_RETRY_COOLDOWN_MS = 60_000; @@ -147,7 +147,7 @@ function initializeWordPressPlane() { // Fire-and-forget: kick off the abilities catalog fetch in the // background so MCP startup is fast. ListTools awaits up to 2s - // for it; per-call self-heal and gv_reload_abilities retry later. + // for it; per-call self-heal and gk_reload_abilities retry later. ensureAbilitiesLoaded(); return true; } catch (wpError) { @@ -201,7 +201,7 @@ async function ensureAbilitiesLoaded({ force = false, timeoutMs } = {}) { }); }) .catch((err) => { - logger.warn(`⚠️ Abilities API catalog unavailable: ${err.message} — abilities tools unavailable until a catalog is reachable (next retry after cooldown, or gv_reload_abilities)`); + logger.warn(`⚠️ Abilities API catalog unavailable: ${err.message} — abilities tools unavailable until a catalog is reachable (next retry after cooldown, or gk_reload_abilities)`); abilitiesFailedAt = Date.now(); abilitiesLoadPromise = null; // clear so a later call retries throw err; @@ -717,7 +717,7 @@ const GF_TOOL_DEFINITIONS = [ const RESERVED_TOOL_NAMES = new Set([ ...GF_TOOL_DEFINITIONS.map((tool) => tool.name), ...fieldOperationTools.map((tool) => tool.name), - 'gv_reload_abilities', + 'gk_reload_abilities', ]); server.setRequestHandler(ListToolsRequestSchema, async () => { @@ -731,7 +731,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { // Best-effort wait for the abilities catalog. 2s covers a warm // cold-start on dev.test (~800ms) plus headroom; if WP is // genuinely unreachable the list ships without gv_* tools and the - // next gv_* call (or gv_reload_abilities) retries. + // next gv_* call (or gk_reload_abilities) retries. await ensureAbilitiesLoaded({ timeoutMs: 2000 }); return { @@ -744,7 +744,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { // GravityView Inspector — auto-generated from the WordPress // Abilities API (Foundation catalog first, WP core fallback). - // Empty until the background load succeeds; gv_reload_abilities + // Empty until the background load succeeds; gk_reload_abilities // and the per-call self-heal repopulate it. ...(abilityToolDefinitions ?? []), @@ -753,7 +753,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { // (e.g. you fixed the WP env and want the tool list refreshed // without waiting to call another gv_* tool first). { - name: 'gv_reload_abilities', + name: 'gk_reload_abilities', description: 'Force a re-fetch of the WordPress Abilities API catalog and refresh the gv_* tool list. Use after fixing a WP / network / cert issue that prevented the eager background load from succeeding at MCP startup.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, inputSchema: { @@ -887,7 +887,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // readable; the map is rebuilt whenever the abilities catalog is // (re)fetched. default: - if (name === 'gv_reload_abilities') { + if (name === 'gk_reload_abilities') { if (!wpClient) { return createErrorResponse( 'WordPress client not initialized. Set GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD in .env (or reuse the GRAVITY_FORMS_* credentials).' @@ -924,7 +924,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const handlerMap = abilityToolHandlers; if (!handlerMap) { return createErrorResponse( - 'GravityView abilities catalog unreachable — no gv_* tools are available. Fix WP connectivity / credentials, then call gv_reload_abilities to refresh.' + 'GravityView abilities catalog unreachable — no gv_* tools are available. Fix WP connectivity / credentials, then call gk_reload_abilities to refresh.' ); } const handler = handlerMap[name]; diff --git a/test/abilities-loader.test.js b/test/abilities-loader.test.js index 270a55a..28208bf 100644 --- a/test/abilities-loader.test.js +++ b/test/abilities-loader.test.js @@ -439,7 +439,7 @@ suite.test('reserved names: catalog tools can never shadow the built-in gf_* con ]; const stub = buildCatalogStubGvClient([colliding]); const { definitions, handlers, count } = await loadAbilitiesAsTools(stub, { - reservedNames: new Set(['gf_list_forms', 'gv_reload_abilities']), + reservedNames: new Set(['gf_list_forms', 'gk_reload_abilities']), }); TestAssert.equal(count, 1, 'reserved-name claimant must be skipped'); TestAssert.deepEqual(definitions.map((d) => d.name), ['gv_views_list']); From fa293281445762c8d7bf2050596b0f5934689ab7 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 19:21:09 -0400 Subject: [PATCH 25/36] docs: overhaul AGENTS.md + add doc-freshness guard [ci skip] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md is now the single source of truth, so accuracy matters. Recast it around two planes — Gravity Forms (primary, 26 gf_* tools) and GravityKit (secondary, dynamic gv_* from the Foundation Abilities catalog; GravityView the only product so far) — documenting the previously-undocumented abilities plane (WordPressClient, abilities loader, gk_reload_abilities, the test/demo-only gravityview/ harness). Removed all brittle file:line citations (cite symbols instead), corrected counts (45 field types; 26 GF tools), and fixed the stale test/ path. Add scripts/check-docs.mjs (npm run lint:docs): an offline guard that fails on doc drift — repo-map coverage (via `git ls-files --cached --others --exclude-standard`, so it respects .gitignore without a hand-rolled parser), tool/field-count mismatches, and any file:line citation. Wired into prepublishOnly alongside publint. Verified: lint:docs, lint:package, and the offline test suites all pass. Live verify:tool-names confirmed the gv_* names against the catalog earlier this session (re-run currently blocked by an unrelated GravityView Composer autoload fatal in the local plugin checkout). --- AGENTS.md | 209 ++++++++++++++++++++++------------------- package.json | 3 +- scripts/check-docs.mjs | 71 ++++++++++++++ 3 files changed, 186 insertions(+), 97 deletions(-) create mode 100644 scripts/check-docs.mjs diff --git a/AGENTS.md b/AGENTS.md index 0d076a7..d8f0346 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — GravityKit MCP -> MCP server providing 26 tools for full Gravity Forms REST API v2 coverage, enabling AI agents to manage forms, entries, feeds, notifications, and fields programmatically. +> MCP server for Gravity Forms (primary) and GravityKit products (secondary). It exposes 26 always-on Gravity Forms tools plus a dynamic set of GravityKit product tools generated from the connected site's Foundation Abilities catalog — GravityView is the only GravityKit product implemented so far. This is the single canonical doc for the project (agents and humans). `CLAUDE.md` simply re-exports it via `@AGENTS.md`. @@ -8,15 +8,20 @@ This is the single canonical doc for the project (agents and humans). `CLAUDE.md - **Package:** `@gravitykit/mcp` v2.1.0 - **Type:** Node.js MCP server (ESM) -- **Purpose:** Full Gravity Forms REST API v2 coverage via 26 MCP tools +- **Purpose:** Full Gravity Forms REST API v2 coverage (26 Gravity Forms tools), plus dynamic GravityKit product tools (GravityView so far) via the WordPress Abilities API - **Repo:** https://github.com/GravityKit/MCP ## Quick Start -**What this is:** A Node.js MCP (Model Context Protocol) server that wraps the Gravity Forms REST API v2. It authenticates via Basic Auth (preferred) or OAuth 1.0a and exposes 26 tools for CRUD operations on forms, entries, feeds, notifications, field filters, results, and intelligent field management. +**What this is:** A Node.js MCP (Model Context Protocol) server with two independent capability planes: + +- **Plane A — Gravity Forms (`gf_*`), primary.** 26 static tools wrapping the Gravity Forms REST API v2 (forms, entries, feeds, notifications, submissions, field filters, results, and intelligent field management). Always available when Gravity Forms REST credentials work — on any Gravity Forms site. +- **Plane B — GravityKit (`gv_*`), secondary.** Tools generated at runtime from the connected site's GravityKit Foundation Abilities catalog. They appear only when Foundation is active. GravityView is the only GravityKit product wired up so far (View authoring, fields, widgets, search, layouts). The plane is product-agnostic: any GravityKit product that registers Foundation abilities shows up automatically. + +The two planes are independent: a GF-only site gets the full `gf_*` surface with no abilities; a GravityKit site without GF REST keys still gets `gv_*`. **Main entry point:** `src/index.js` -**Architecture style:** MCP SDK server with stdio transport, single API client, composable validation +**Architecture style:** MCP SDK server with stdio transport, one HTTP client per plane, composable validation **Key dependency:** `@modelcontextprotocol/sdk` ^1.0.0 ## Repository Map @@ -29,15 +34,21 @@ MCP/ ├── AGENTS.md # Canonical agent + developer docs (this file) ├── CLAUDE.md # Claude Code entry point — re-exports AGENTS.md (@AGENTS.md) ├── src/ -│ ├── index.js # Server bootstrap, tool registration, handler routing -│ ├── gravity-forms-client.js # GravityFormsClient: HTTP client, all API methods -│ ├── field-operations/ # Intelligent field management layer +│ ├── index.js # Server bootstrap, two-plane init, tool registration, handler routing +│ ├── gravity-forms-client.js # GravityFormsClient: GF REST HTTP client, all gf_* API methods +│ ├── wp-client.js # WordPressClient: product-agnostic authenticated WP transport (Plane B) +│ ├── abilities/ +│ │ └── loader.js # loadAbilitiesAsTools() — turns the live Abilities catalog into gv_* tools +│ ├── gravityview/ # GravityView test/demo harness (NOT runtime — gv_* come from abilities/) +│ │ ├── inspector-client.js # Client for /wp-json/gravityview/v1 (only when DOING_GRAVITYVIEW_TESTS) +│ │ └── view-validator.js # Client-side structural + schema-aware validation for the inspector +│ ├── field-operations/ # Intelligent field management layer (gf_* field tools) │ │ ├── index.js # Factory, tool definitions, handler functions │ │ ├── field-manager.js # FieldManager: CRUD orchestrator │ │ ├── field-dependencies.js # DependencyTracker: conditional logic/merge tag scanning │ │ └── field-positioner.js # PositionEngine: page-aware field positioning │ ├── field-definitions/ -│ │ ├── field-registry.js # 44 field types with metadata, validation, storage patterns +│ │ ├── field-registry.js # 45 field types with metadata, validation, storage patterns │ │ └── loader.js # Registry loader │ ├── config/ │ │ ├── auth.js # BasicAuthHandler, OAuth1Handler, AuthManager @@ -61,12 +72,13 @@ MCP/ │ └── *.test.js # forms, entries, feeds, fields, validation, submissions, compact, sanitize, … ├── scripts/ │ ├── check-env.js # Environment validation script +│ ├── check-docs.mjs # Doc-freshness guard for AGENTS.md (offline; npm run lint:docs) +│ ├── verify-tool-names.mjs # Cross-check doc/instruction tool names vs registered tools (needs live site) +│ ├── stress-abilities.mjs # Synthetic abilities-loader stress/contract test │ ├── setup-test-data.js # Test data seeding │ ├── test-field-ops.js # Field operations smoke test │ ├── test-server-output.js # Server output verification -│ ├── verify-field-tools.js # Field tool registration check -│ ├── verify-tool-names.mjs # Cross-check doc/instruction tool names vs registered tools (dev-only, not published) -│ └── stress-abilities.mjs # Synthetic abilities-loader stress/contract test +│ └── verify-field-tools.js # Field tool registration check └── .github/workflows/ ├── publish.yml # npm publish workflow ├── security.yml # Security scanning @@ -75,43 +87,50 @@ MCP/ ## Architecture +### Two capability planes + +The server registers tools from two independent sources, initialized separately so a failure in one never blocks the other: + +- **Plane A — Gravity Forms (`gf_*`).** Static tool definitions in `src/index.js` (`GF_TOOL_DEFINITIONS`) plus the field tools from `src/field-operations/index.js` (`fieldOperationTools`). Backed by `GravityFormsClient` against the GF REST API v2. 26 tools, always present once GF credentials validate. +- **Plane B — GravityKit (`gv_*`).** Generated at runtime by `src/abilities/loader.js` from the connected site's Abilities catalog, backed by `WordPressClient`. The catalog is fetched in the background after startup; tools appear once it loads (the server advertises `tools.listChanged`). A single built-in tool, `gk_reload_abilities`, forces a re-fetch. + ### Initialization Flow -1. `src/index.js` loads env vars via dotenv (CWD first, then project dir) — `:30-32` -2. Creates MCP `Server` instance with `tools` capability — `:35-45` -3. `initializeClient()` constructs `GravityFormsClient` with `process.env` — `:57` -4. Client creates `AuthManager` which selects Basic or OAuth handler — `config/auth.js:223-268` -5. Client creates axios instance with auth interceptor — `gravity-forms-client.js:22-85` -6. `validateRestApiAccess()` tests Forms/Entries/Feeds endpoints — `config/auth.js:301-368` -7. Field operations initialized: `FieldManager`, `DependencyTracker`, `PositionEngine` — `:64-70` -8. Server connects to `StdioServerTransport` — `:641-644` +1. `src/index.js` loads env vars via dotenv (CWD first, then project dir). +2. Creates the MCP `Server` with `tools.listChanged` capability and the two-plane server instructions. +3. **Plane A:** `initializeClient()` constructs `GravityFormsClient` from `process.env`; its `AuthManager` selects Basic or OAuth; `validateRestApiAccess()` probes Forms/Entries/Feeds; field operations (`FieldManager`, `DependencyTracker`, `PositionEngine`) are wired up. +4. **Plane B:** constructs `WordPressClient` and kicks off a fire-and-forget abilities catalog fetch (`loadAbilitiesAsTools`). Startup never blocks on it; failures self-heal on the next `gv_*` call (after a cooldown) or via `gk_reload_abilities`. +5. Server connects to `StdioServerTransport`. ### Core Concepts -**GravityFormsClient** (`gravity-forms-client.js`): Single class wrapping all API endpoints. Each method uses `validateAndCall(toolName, input, apiCall)` pattern — validates input via `ValidationFactory`, then executes the HTTP call. Update operations (forms, entries, feeds) fetch-then-merge to preserve existing data. Responses return minimal payloads — just the essential data without redundant metadata. +**GravityFormsClient** (`gravity-forms-client.js`): Single class wrapping all GF API endpoints. Each method uses the `validateAndCall(toolName, input, apiCall)` pattern — validates input via `ValidationFactory`, then executes the HTTP call. Update operations (forms, entries, feeds) fetch-then-merge to preserve existing data. Returns minimal payloads. + +**WordPressClient** (`wp-client.js`): Product-agnostic authenticated WordPress transport for Plane B. The abilities loader rides it to reach the Foundation catalog (`/wp-json/gravitykit/v1/...`) and the WP core Abilities API (`/wp-json/wp-abilities/v1/...`). Auth is a WordPress Application Password via HTTP Basic; when `GRAVITYKIT_WP_*` creds aren't set it falls back to `GRAVITY_FORMS_CONSUMER_KEY`/`SECRET` (commonly the same WP user + app password). + +**Abilities loader** (`abilities/loader.js`): `loadAbilitiesAsTools(wpClient)` builds `gv_*` tool definitions + handlers from the live catalog. Source-preference chain: (1) Foundation catalog `/wp-json/gravitykit/v1/abilities` (server-filtered to GravityKit, server-owned tool names), (2) WP core catalog `/wp-json/wp-abilities/v1/abilities` (filtered client-side on Foundation's stamped `meta.gk_registered_by`), (3) throw if neither is reachable (caller leaves `gv_*` unregistered and retries — self-healing). **Tool names are owned by the server** via each ability's `mcp_tool_name` (from the product's `mcp_prefix`, or the full product slug); abilities without one are skipped with a warning rather than client-invented. Handlers execute abilities at `/wp-abilities/v1/abilities/{name}/run` with the HTTP method derived from annotations (`readonly` → GET, `destructive`+`idempotent` → DELETE, else POST). -**AuthManager** (`config/auth.js`): Credential-aware selection between `BasicAuthHandler` and `OAuth1Handler` — app-password creds get Basic (HTTPS or local URLs); `ck_`/`cs_` key pairs get Basic on HTTPS, OAuth on plain HTTP (matching the GF server's `is_ssl()` gate for key Basic auth). Explicit `GRAVITY_FORMS_AUTH_METHOD` overrides. Auth headers injected via axios request interceptor. +**GravityView harness** (`gravityview/inspector-client.js`, `view-validator.js`): The Inspector client and validator target `/wp-json/gravityview/v1` routes that exist only when `DOING_GRAVITYVIEW_TESTS` is defined server-side. They are the integration-test and demo harness — **not** a runtime dependency. Runtime `gv_*` tools come from the abilities loader. + +**AuthManager** (`config/auth.js`): Credential-aware selection between `BasicAuthHandler` and `OAuth1Handler` — app-password creds get Basic (HTTPS or local URLs); `ck_`/`cs_` key pairs get Basic on HTTPS, OAuth on plain HTTP (matching the GF server's `is_ssl()` gate for key Basic auth). Explicit `GRAVITY_FORMS_AUTH_METHOD` overrides. **ValidationFactory** (`config/validation.js`): Central validation dispatcher. `validateToolInput(toolName, input)` routes to domain-specific validators. Composable rule chains (`validation-chain.js`) for reusable validation logic. -**FieldManager** (`field-operations/field-manager.js`): Handles field CRUD within REST API v2 constraints (fields are properties of form objects, not separate endpoints). Generates integer IDs via max+1 pattern, creates compound sub-inputs for address/name/creditcard fields. +**FieldManager** (`field-operations/field-manager.js`): Handles field CRUD within REST API v2 constraints (fields are properties of form objects, not separate endpoints). Generates integer IDs via max+1, creates compound sub-inputs for address/name/creditcard fields. -**Field Registry** (`field-definitions/field-registry.js`): Metadata for all 44 Gravity Forms field types including categories, storage patterns (simple/compound/special), validation rules, variants, and capability flags. +**Field Registry** (`field-definitions/field-registry.js`): Metadata for all 45 Gravity Forms field types — categories, storage patterns (simple/compound/special), validation rules, variants, and capability flags. ### Data Flow ``` MCP Client → stdio → Server.CallToolRequestSchema handler - → switch(name) routes to handler - → wrapHandler() wraps execution: - → GravityFormsClient.method(params) - → validateAndCall(toolName, input, apiCall) - → ValidationFactory.validateToolInput() → validated input - → apiCall(validatedInput) → axios HTTP request - → auth interceptor adds headers - → response interceptor handles errors - → minimal result object (no redundant fields) - → JSON.stringify(result) → compact MCP content block (no pretty-print) + → switch(name): + gf_* / field tools → wrapHandler(GravityFormsClient.method) + → validateAndCall → ValidationFactory → axios (auth interceptor) + → minimal result + gv_* → ability handler → WordPressClient → /wp-abilities/v1/.../run + gk_reload_abilities → force catalog re-fetch + → JSON.stringify(result) → compact MCP content block (no pretty-print) ← { content: [{ type: "text", text: "..." }] } ``` @@ -119,14 +138,16 @@ MCP Client → stdio → Server.CallToolRequestSchema handler Responses are optimized for minimal token usage: -- **Compact JSON**: `JSON.stringify(result)` — no pretty-printing (no `null, 2`) — `src/index.js:114` -- **Minimal payloads**: No redundant `message`, `created`/`updated` booleans, or echo-back of input IDs. GET methods return `{ resource: data }`, mutations return only what can't be inferred (e.g., delete returns `{ deleted: true, id, permanently }`) -- **Summary/detail modes**: `gf_list_field_types` defaults to summary mode (`type`, `label`, `category` only). Pass `detail=true` for full metadata (supports, storage, validation, icon). Pass `include_variants=true` with `detail=true` for variant data. -- **Compact mode (default on)**: `stripEmpty()` (`utils/compact.js`) recursively removes `null` and `""` values from all responses via `wrapHandler()`. `false` is preserved (semantic meaning). Entry tools also strip plugin-added meta keys (e.g., `gv_revision_*`, `helpscout_conversation_id`) via `stripEntryMeta()`, keeping only core properties and numbered field values. Pass `compact=false` for full raw data. -- **Concise tool descriptions**: All 28 tool descriptions and property descriptions are terse to reduce tool-list overhead +- **Compact JSON**: `JSON.stringify(result)` — no pretty-printing (no `null, 2`). +- **Minimal payloads**: No redundant `message`, `created`/`updated` booleans, or echo-back of input IDs. GET methods return `{ resource: data }`; mutations return only what can't be inferred (e.g., delete returns `{ deleted: true, id, permanently }`). +- **Summary/detail modes**: `gf_list_field_types` defaults to summary mode (`type`, `label`, `category`). Pass `detail=true` for full metadata; add `include_variants=true` for variant data. +- **Compact mode (default on)**: `stripEmpty()` (`utils/compact.js`) recursively removes `null` and `""` from all responses. `false` is preserved (semantic meaning). Entry tools also strip plugin-added meta keys (e.g., `gv_revision_*`, `helpscout_conversation_id`) via `stripEntryMeta()`, keeping only core properties and numbered field values. Pass `compact=false` for full raw data. +- **Terse descriptions**: All tool and property descriptions are kept terse to reduce `tools/list` overhead. ### Tool Categories +**Plane A — Gravity Forms (`gf_*`), 26 static tools:** + | Category | Tools | Client Methods | |----------|-------|----------------| | Forms | `gf_list_forms`, `gf_get_form`, `gf_create_form`, `gf_update_form`, `gf_delete_form`, `gf_validate_form` | `listForms`, `getForm`, `createForm`, `updateForm`, `deleteForm`, `validateForm` | @@ -135,7 +156,9 @@ Responses are optimized for minimal token usage: | Notifications | `gf_send_notifications` | `sendNotifications` | | Feeds | `gf_list_feeds`, `gf_get_feed`, `gf_create_feed`, `gf_update_feed`, `gf_patch_feed`, `gf_delete_feed` | `listFeeds`, `getFeed`, `createFeed`, `updateFeed`, `patchFeed`, `deleteFeed` | | Utilities | `gf_get_field_filters`, `gf_get_results` | `getFieldFilters`, `getResults` | -| Field Ops | `gf_add_field`, `gf_update_field`, `gf_delete_field`, `gf_list_field_types` | Handled via `fieldOperationHandlers` → `FieldManager` | +| Field Ops | `gf_add_field`, `gf_update_field`, `gf_delete_field`, `gf_list_field_types` | via `fieldOperationHandlers` → `FieldManager` | + +**Plane B — GravityKit (`gv_*`), dynamic.** Generated from the catalog, so the exact set depends on the connected site's GravityKit products and versions — discover at runtime, don't hard-code. GravityView currently contributes tool families for View lifecycle (`gv_view_create`, `gv_view_config_apply`, `gv_view_delete`, …), fields (`gv_view_field_add`/`patch`/`move`/`remove`), grid rows, widgets, search fields, and discovery/schema (`gv_layouts_list`, `gv_widgets_list`, `gv_field_type_schema_get`, `gv_available_fields_get`, …). Plus the built-in `gk_reload_abilities`. Use the `gv_*_list` discovery tools and `gv_field_type_schema_get` to introspect what's available; the server `instructions` string documents the GravityView authoring flow. To re-verify that prose tool names still match the live catalog, run `npm run verify:tool-names` (see Releasing). ### Response Shapes @@ -163,21 +186,21 @@ Mutation methods return minimal confirmation: ### File & Class Naming - Files: `kebab-case.js` (e.g., `field-manager.js`, `gravity-forms-client.js`) -- Classes: `PascalCase` (e.g., `GravityFormsClient`, `FieldManager`, `AuthManager`) -- Exports: Named exports for classes, default export for primary class per file -- Test files: `{module-name}.test.js` alongside or in `tests/` directory +- Classes: `PascalCase` (e.g., `GravityFormsClient`, `FieldManager`, `WordPressClient`) +- Exports: Named exports for classes, default export for the primary class per file +- Test files: `{module-name}.test.js` in the top-level `test/` directory ### Module System - ESM throughout (`"type": "module"` in package.json) - All imports use `.js` extension (required for ESM) -- `__dirname` shimmed via `fileURLToPath(import.meta.url)` in `src/index.js:25-26` +- `__dirname` shimmed via `fileURLToPath(import.meta.url)` where needed ### Error Handling Pattern -All tool handlers use `wrapHandler()` (`src/index.js:99-125`): +All tool handlers use `wrapHandler()` in `src/index.js`: - Checks client initialization -- Wraps result in MCP content blocks `{ content: [{ type: "text", text: JSON.stringify(result) }] }` +- Wraps the result in MCP content blocks `{ content: [{ type: "text", text: JSON.stringify(result) }] }` - Catches errors → `createErrorResponse()` with sanitized details - Error details pass through `sanitize()` to mask credentials @@ -202,17 +225,17 @@ await this.httpClient.put(`/resource/${id}`, merged); ### Delete Safety -All delete operations (`deleteForm`, `deleteEntry`, `deleteFeed`) check `this.allowDelete` first, controlled by `GRAVITY_FORMS_ALLOW_DELETE=true` env var. Without it, deletes throw immediately. +All GF delete operations (`deleteForm`, `deleteEntry`, `deleteFeed`) check `this.allowDelete` first, controlled by `GRAVITY_FORMS_ALLOW_DELETE=true`. Without it, deletes throw immediately. ### Logging -`utils/logger.js` routes all logs to stderr in MCP mode (keeps stdout clean for JSON-RPC). In test mode, uses console.log. Sensitive data masked via `utils/sanitize.js`. +`utils/logger.js` routes all logs to stderr in MCP mode (keeps stdout clean for JSON-RPC). In test mode it uses console.log. Sensitive data is masked via `utils/sanitize.js`. Never use `console.log` in server code. ## Extension Patterns -### Adding a New Tool +### Adding a New Gravity Forms Tool -1. **Define the tool schema** in `src/index.js` inside the `ListToolsRequestSchema` handler (`:131-519`). Add to the tools array with concise descriptions: +1. **Define the tool schema** in `src/index.js` (in `GF_TOOL_DEFINITIONS`, surfaced by the `ListToolsRequestSchema` handler) with a concise description: ```javascript { name: 'gf_new_tool', @@ -220,31 +243,14 @@ All delete operations (`deleteForm`, `deleteEntry`, `deleteFeed`) check `this.al inputSchema: { type: 'object', properties: {...}, required: [...] } } ``` +2. **Add the client method** in `gravity-forms-client.js` using `validateAndCall`, returning minimal data. +3. **Add validation** in `config/validation.js` inside `ValidationFactory.validateToolInput()`. +4. **Add the handler route** in the `CallToolRequestSchema` switch in `src/index.js`. +5. **Add tests** in `test/`, importing the source under test as `../src/…` (see `forms.test.js`). -2. **Add the client method** in `gravity-forms-client.js` using `validateAndCall`. Return minimal data: - ```javascript - async newToolMethod(params) { - return this.validateAndCall('gf_new_tool', params, async (validated) => { - const response = await this.httpClient.get('/endpoint'); - return { data: response.data }; // No message, no echo-back IDs - }); - } - ``` - -3. **Add validation** in `config/validation.js` inside `ValidationFactory.validateToolInput()` (`:463-628`): - ```javascript - case 'gf_new_tool': - BaseValidator.validateRequired(input, ['required_field']); - return { required_field: BaseValidator.validateId(input.required_field) }; - ``` +### Adding GravityKit (`gv_*`) Tools -4. **Add the handler route** in `src/index.js` inside the `CallToolRequestSchema` handler switch (`:537-628`): - ```javascript - case 'gf_new_tool': - return wrapHandler(() => gravityFormsClient.newToolMethod(params))(); - ``` - -5. **Add tests** — create test in `test/` following existing patterns (see `forms.test.js` for reference). Import source under test as `../src/…`. +`gv_*` tools are **not** defined in this repo — they come from the connected site's Foundation Abilities catalog. To add or change them, register/modify abilities in the relevant GravityKit product (the server stamps each ability's `mcp_tool_name`); the loader picks them up automatically. After a catalog change, run `gk_reload_abilities` (live) or `npm run verify:tool-names` to confirm names. ### Adding a New Field Type to the Registry @@ -261,8 +267,7 @@ newfield: { variants: { default: { label: 'Default', settings: {} } } } ``` - -For compound fields (multi-input like address/name), set `storage.type: 'compound'` and add sub-input generation logic in `field-manager.js:generateSubInputs()` (`:206-267`). +For compound fields (multi-input like address/name), set `storage.type: 'compound'` and add sub-input generation logic in `field-manager.js` (`generateSubInputs()`). ### Adding a New Validation Rule @@ -282,7 +287,7 @@ npm run dev # Dev with auto-reload npm run inspect # Debug with MCP Inspector ``` -### Required Environment +### Required Environment (Plane A — Gravity Forms) ``` GRAVITY_FORMS_BASE_URL=https://... # WordPress site URL (no trailing slash) @@ -297,6 +302,17 @@ GRAVITY_FORMS_CONSUMER_SECRET="xxxx xxxx xxxx xxxx xxxx xxxx" Shorthand aliases `GF_CONSUMER_KEY`, `GF_CONSUMER_SECRET`, `GF_URL` are also supported (resolved in `test-config.js`). +### GravityKit Environment (Plane B — abilities) + +``` +# Optional — only needed for gv_* tools. Falls back to GRAVITY_FORMS_* when unset. +GRAVITYKIT_WP_URL=https://... # WordPress site URL (usually same as GF) +GRAVITYKIT_WP_USERNAME=wp_username +GRAVITYKIT_WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx" +``` + +`WordPressClient` resolves the base URL from `GRAVITYKIT_WP_URL` or `GRAVITY_FORMS_BASE_URL`, and credentials from `GRAVITYKIT_WP_*` or the `GRAVITY_FORMS_CONSUMER_KEY`/`SECRET` fallback. On most single-site setups the GF credentials already double as the WP app password, so no extra config is needed. + ### Optional Environment ``` @@ -322,9 +338,7 @@ GRAVITY_FORMS_TEST_TIMEOUT= # Override timeout for test site GRAVITYKIT_MCP_TEST_MODE=true # Enable test mode (remaps TEST_* vars) ``` -Shorthand aliases: `TEST_GF_URL`, `TEST_GF_CONSUMER_KEY`, `TEST_GF_CONSUMER_SECRET`, `TEST_WP_USER`, `TEST_WP_PASSWORD`. - -Legacy: `GRAVITYMCP_TEST_MODE` and `GRAVITY_FORMS_TEST_URL` are also supported. Test mode also activates when `NODE_ENV=test`. +Shorthand aliases: `TEST_GF_URL`, `TEST_GF_CONSUMER_KEY`, `TEST_GF_CONSUMER_SECRET`, `TEST_WP_USER`, `TEST_WP_PASSWORD`. Legacy: `GRAVITYMCP_TEST_MODE` and `GRAVITY_FORMS_TEST_URL` are also supported. Test mode also activates when `NODE_ENV=test`. ### Testing @@ -335,13 +349,12 @@ npm run test:forms # Forms endpoint tests npm run test:entries # Entries endpoint tests npm run test:feeds # Feeds endpoint tests npm run test:tools # Tool registration validation +npm run test:views # GravityView inspector/validator tests npm run test:all # Run everything sequentially npm test # Integration tests (requires live API) ``` -Tests use a custom runner (`test/run.js`), not Jest/Mocha. Test helpers in `test/helpers.js` provide mock data generators (`generateMockForm`, `generateMockEntry`, `generateMockFeed`). - -For integration tests, set `GRAVITY_FORMS_TEST_*` env vars pointing to a test WordPress site. Test forms are prefixed with `TEST_` and auto-cleaned via `TestFormManager`. +Tests use a custom runner (`test/run.js`), not Jest/Mocha. Test helpers in `test/helpers.js` provide mock data generators (`generateMockForm`, `generateMockEntry`, `generateMockFeed`). For integration tests, set `GRAVITY_FORMS_TEST_*` env vars pointing to a test WordPress site; test forms are prefixed with `TEST_` and auto-cleaned via `TestFormManager`. ### Building @@ -349,29 +362,31 @@ No build step — pure ESM JavaScript, runs directly with `node src/index.js`. R ## Gotchas -1. **Fields are form properties, not separate endpoints.** The Gravity Forms REST API has no direct field CRUD endpoints. All field operations require fetching the entire form, modifying the fields array, then PUT-ing the whole form back. This is why `FieldManager` exists as a layer on top of `GravityFormsClient`. — `field-manager.js:31-56` +1. **Fields are form properties, not separate endpoints.** The Gravity Forms REST API has no direct field CRUD endpoints. All field operations fetch the entire form, modify the fields array, then PUT the whole form back. This is why `FieldManager` exists as a layer on top of `GravityFormsClient`. + +2. **Stdout is reserved for JSON-RPC.** In MCP mode, ALL logging must go to stderr. `console.log` corrupts the transport. Use `logger.info/error/warn`. -2. **Stdout is reserved for JSON-RPC.** In MCP mode, ALL logging must go to stderr. Using `console.log` will corrupt the JSON-RPC transport. The `logger.js` utility handles this, but any new code must use `logger.info/error/warn` instead of `console.log`. — `utils/logger.js:14-32` +3. **Auth method is credential-aware.** `AuthManager` picks the transport from the credential shape: app-password creds use Basic over HTTPS or local URLs; `ck_`/`cs_` key pairs use Basic over HTTPS and OAuth 1.0a over plain HTTP (Gravity Forms only checks key-pair Basic auth when `is_ssl()`). An explicit `GRAVITY_FORMS_AUTH_METHOD` is always honored — including `basic` over remote HTTP, so don't set it in `.env` "just in case". Remote-HTTP Basic without an explicit method needs `GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true`. -3. **Auth method is credential-aware.** `AuthManager` picks the transport from the credential shape: app-password creds (username + application password) use Basic over HTTPS or local URLs; `ck_`/`cs_` key pairs use Basic over HTTPS and OAuth 1.0a over plain HTTP (Gravity Forms only checks key-pair Basic auth when `is_ssl()`). An explicit `GRAVITY_FORMS_AUTH_METHOD` is always honored — including `basic` over remote HTTP, so don't set it in `.env` "just in case". Remote-HTTP Basic without an explicit method needs `GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true`. — `config/auth.js` +4. **Update operations fetch-then-merge.** `updateForm`, `updateEntry`, and `updateFeed` GET the existing resource, merge, then PUT — two HTTP calls per update. If the resource changes between GET and PUT, the intermediate change is overwritten. -4. **Update operations fetch-then-merge.** `updateForm`, `updateEntry`, and `updateFeed` all GET the existing resource first, merge updates, then PUT. This prevents data loss but means two HTTP calls per update. If the resource is modified between GET and PUT, the intermediate change is overwritten. — `gravity-forms-client.js:262-278` +5. **Field ID generation uses max+1.** If field ID 10 is deleted, the next field gets ID 11, not 10. IDs are never reused within a form. -5. **Field ID generation uses max+1.** If a field with ID 10 is deleted, the next field gets ID 11, not 10. IDs are never reused within a form. — `field-manager.js:173-182` +6. **Compound field sub-input IDs use dot notation.** Address field 5 has sub-inputs `5.1` (street), `5.2` (line 2), etc. These IDs are strings, not numbers. Entry data uses these dot-notation keys. -6. **Compound field sub-input IDs use dot notation.** Address field 5 has sub-inputs `5.1` (street), `5.2` (line 2), etc. These IDs are strings, not numbers. Entry data uses these dot-notation keys. — `field-manager.js:206-267` +7. **Delete operations are disabled by default.** `GRAVITY_FORMS_ALLOW_DELETE=true` must be set explicitly, or `deleteForm`/`deleteEntry`/`deleteFeed` throw. Intentional safety. -7. **Delete operations are disabled by default.** `GRAVITY_FORMS_ALLOW_DELETE=true` must be explicitly set. Without it, `deleteForm`, `deleteEntry`, and `deleteFeed` throw immediately. This is intentional safety. — `gravity-forms-client.js:88, 292-294` +8. **`mcp.json` may be stale.** The runtime source of truth is `GF_TOOL_DEFINITIONS` + the `ListToolsRequestSchema` handler in `src/index.js`, `fieldOperationTools` in `field-operations/index.js` (26 `gf_*` tools), the built-in `gk_reload_abilities`, and the dynamic `gv_*` tools from the abilities loader. -8. **The `mcp.json` manifest may be stale.** The `ListToolsRequestSchema` handler in `index.js` plus `fieldOperationTools` in `field-operations/index.js` are the source of truth (22 + 4 = 26 tools total). — `src/index.js` + `src/field-operations/index.js` +9. **Self-signed certs for local dev.** Set `GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true` to bypass certificate validation for local WordPress (Laravel Valet, Local WP, etc.). Never in production. -9. **Self-signed certs for local dev.** Set `GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true` to bypass certificate validation for local WordPress environments (Laravel Valet, Local WP, etc.). Never enable in production. — `gravity-forms-client.js:31-33` +10. **Validation has legacy and new patterns.** A `BaseValidator` legacy layer wraps the newer `ValidationChain` and domain validators. Both paths are active. New code should use the chain system in `validation-chain.js`. -10. **Validation has legacy and new patterns.** The validation system has a `BaseValidator` legacy layer wrapping newer `ValidationChain` and domain-specific validators. Both paths are active. New code should use the chain system in `validation-chain.js`. — `config/validation.js:21-260` +11. **`gf_list_field_types` defaults to summary mode.** Returns only `type`, `label`, `category`. Pass `detail=true` for full metadata; add `include_variants=true` for variants. Prevents dumping thousands of tokens for all 45 field types. -11. **`gf_list_field_types` defaults to summary mode.** Returns only `type`, `label`, `category` per field type. Pass `detail=true` for full metadata (supports, storage, validation). Pass `include_variants=true` with `detail=true` for variant data. This prevents accidentally dumping thousands of tokens for all 44 field types. — `field-operations/index.js:142-211` +12. **Test mode resolves env vars at client construction.** When `GRAVITYKIT_MCP_TEST_MODE=true` (or legacy `GRAVITYMCP_TEST_MODE=true`), `testConfig.resolveEnv()` remaps `GRAVITY_FORMS_TEST_*` → `GRAVITY_FORMS_*`. The rest of the client and AuthManager work unchanged. -12. **Test mode resolves env vars at client construction.** When `GRAVITYKIT_MCP_TEST_MODE=true` (or legacy `GRAVITYMCP_TEST_MODE=true`), `testConfig.resolveEnv()` remaps `GRAVITY_FORMS_TEST_BASE_URL` → `GRAVITY_FORMS_BASE_URL` (and consumer key/secret). The rest of the client and AuthManager work unchanged. — `config/test-config.js:60-95`, `gravity-forms-client.js:16` +13. **`gv_*` tools load asynchronously and self-heal.** The abilities catalog is fetched in the background after startup, so `gv_*` tools may be absent for a moment (the server emits a `tools/listChanged` once they arrive). If a catalog fetch fails, it retries after a cooldown or immediately on `gk_reload_abilities`. The `src/gravityview/` Inspector client is a test/demo harness only — runtime `gv_*` come from the abilities loader. ## Packaging @@ -379,7 +394,7 @@ What ships to npm is governed solely by the **`files` allowlist** in `package.js - **Ships:** `src/` (runtime), `mcp.json`, `.env.example`, `README.md`, `LICENSE`, `CLAUDE.md`, `AGENTS.md`. - **Excluded by omission:** `test/` (tests are top-level, not under `src/`), `scripts/` (dev tooling), `.github/`, `package-lock.json`. -- **`npm run lint:package`** runs [publint](https://publint.dev) to validate package correctness; **`prepublishOnly`** runs the offline test suites + publint, so a broken or mis-packaged build can't be published. It deliberately omits the live integration test (`npm test`) to avoid hitting a real site during publish. +- **`npm run lint:package`** runs [publint](https://publint.dev) to validate package correctness; **`npm run lint:docs`** runs the offline doc-freshness guard (`scripts/check-docs.mjs`). **`prepublishOnly`** runs the offline test suites + both linters, so a broken, mis-packaged, or stale-documented build can't be published. It deliberately omits the live integration test (`npm test`) to avoid hitting a real site during publish. - **Verify before publishing:** `npm pack --dry-run` lists exactly what will ship. ## Releasing @@ -394,9 +409,11 @@ What ships to npm is governed solely by the **`files` allowlist** in `package.js 6. **Tag**: `git tag vX.Y.Z` 7. **Push**: `git push origin main --tags` -Skipping any step (especially CHANGELOG) will leave the release history incomplete for future developers and AI agents. +Skipping any step (especially CHANGELOG) leaves the release history incomplete. -**Before tagging, run `npm run verify:tool-names` against a live site.** The `gv_*` tools are generated from the installed GravityView/Foundation Abilities catalog, so a catalog rename can silently leave the server `instructions` string, README, or the demo referencing tools that no longer exist. The script cross-checks every `gf_`/`gv_` name in prose against what the server actually registers and exits non-zero on a mismatch. Requires WordPress credentials in the environment (see Required Environment). Dev-only — not shipped in the npm package. +**Before tagging:** +- Run **`npm run lint:docs`** — the offline doc-freshness guard (repo-map coverage, tool/field counts, no line citations). `prepublishOnly` runs it too. +- Run **`npm run verify:tool-names` against a live site** — the `gv_*` tools are generated from the installed GravityView/Foundation Abilities catalog, so a catalog rename can silently leave the server `instructions` string, README, or the demo referencing tools that no longer exist. The script cross-checks every `gf_`/`gv_` name in prose against what the server registers and exits non-zero on a mismatch. Needs WordPress credentials (see GravityKit Environment). Dev-only — not shipped in the npm package. ## Related Resources diff --git a/package.json b/package.json index 6906c3e..45bdb61 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test:all": "npm run test:unit && npm run test:field-ops && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm run test:views && npm test", "test:coverage": "echo 'Running all tests with coverage analysis' && npm run test:all", "lint:package": "publint", - "prepublishOnly": "npm run test:unit && npm run test:field-ops && npm run test:field-validation && npm run test:views && publint" + "lint:docs": "node scripts/check-docs.mjs", + "prepublishOnly": "npm run test:unit && npm run test:field-ops && npm run test:field-validation && npm run test:views && npm run lint:package && npm run lint:docs" }, "keywords": [ "mcp", diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs new file mode 100644 index 0000000..81a92d6 --- /dev/null +++ b/scripts/check-docs.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Offline doc-freshness guard for AGENTS.md (the single canonical doc). + * + * Catches the drift classes this project has actually hit: a new src/ dir + * nobody documented, stale tool/field counts, brittle file:line citations, + * and a renamed built-in. Pure static analysis — no network, safe for + * prepublishOnly. Exit 0 = fresh, 1 = drift. + * + * Run via `npm run lint:docs`. + */ +import { readFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { fieldRegistry } from '../src/field-definitions/field-registry.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); +const agents = read('AGENTS.md'); +const problems = []; + +// 1. Repo map / docs mention every immediate child of src/ that isn't +// git-ignored. `git ls-files --cached --others --exclude-standard` lets git +// apply .gitignore for us (no hand-rolled parser): it lists on-disk files +// minus ignored ones, so junk like .DS_Store drops out because it's ignored, +// new not-yet-committed files are still caught, and tracked dotfiles count. +try { + const srcChildren = [...new Set( + execSync('git ls-files --cached --others --exclude-standard src', { cwd: ROOT, encoding: 'utf8' }) + .split('\n').filter(Boolean) + .map((p) => p.replace(/^src\//, '').split('/')[0]) + )]; + for (const child of srcChildren) { + if (!agents.includes(child)) problems.push(`src/ entry never mentioned in AGENTS.md: ${child}`); + } +} catch { + console.warn(' (repo-map coverage check skipped — git not available)'); +} + +// 2. "N field types" claims match the registry. +const fieldCount = Object.keys(fieldRegistry).length; +const fieldClaims = [...agents.matchAll(/(\d+)\s+(?:Gravity Forms\s+)?field types/g)].map((m) => Number(m[1])); +if (!fieldClaims.length) problems.push('No "N field types" claim found in AGENTS.md'); +for (const n of fieldClaims) if (n !== fieldCount) problems.push(`Field-type count drift: AGENTS.md says ${n}, registry has ${fieldCount}`); + +// 3. "N Gravity Forms tools" claims match the registered gf_* tools. +const gfRe = /name:\s*'(gf_[a-z0-9_]+)'/g; +const gfTools = new Set([ + ...[...read('src/index.js').matchAll(gfRe)].map((m) => m[1]), + ...[...read('src/field-operations/index.js').matchAll(gfRe)].map((m) => m[1]), +]); +const toolClaims = [...agents.matchAll(/(\d+)\s+(?:[\w-]+\s+)?Gravity Forms tools/g)].map((m) => Number(m[1])); +if (!toolClaims.length) problems.push('No "N Gravity Forms tools" claim found in AGENTS.md'); +for (const n of toolClaims) if (n !== gfTools.size) problems.push(`GF tool-count drift: AGENTS.md says ${n}, code registers ${gfTools.size}`); + +// 4. No brittle file:line citations (policy: cite symbols, not lines). +const cites = [...agents.matchAll(/[\w./-]+\.(?:js|mjs|json|php):\d+(?:-\d+)?|`:\d+(?:-\d+)?`/g)].map((m) => m[0]); +if (cites.length) problems.push(`${cites.length} file:line citation(s) — cite symbols, not lines: ${[...new Set(cites)].slice(0, 6).join(', ')}`); + +// 5. The GravityKit reload built-in is named gk_reload_abilities and registered. +if (agents.includes('gk_reload_abilities') && !read('src/index.js').includes("name: 'gk_reload_abilities'")) { + problems.push('AGENTS.md references gk_reload_abilities but src/index.js does not register a tool with that name'); +} + +if (problems.length) { + console.error('❌ AGENTS.md doc-freshness check failed:'); + for (const p of problems) console.error(' • ' + p); + process.exit(1); +} +console.log(`✅ AGENTS.md doc-freshness OK — ${gfTools.size} gf_* tools, ${fieldCount} field types, repo map covers src/, no line citations`); From a7ec1236a8e94e57bf78c9baaba5e0ac99f21dac Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 20:15:00 -0400 Subject: [PATCH 26/36] fix: paginate abilities catalog in verify-tool-names; test robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (verified live against the running site): - verify-tool-names: the WP Abilities endpoint paginates (default per_page 50; the site has 51 abilities over 2 pages), so the single-page fetch missed gk-gravityview/* names on page 2 — abilityNames was 48 vs the loader's 49. Walk all pages (per_page=100 + X-WP-TotalPages). Now 49/49. - authentication.test.js: isMain basename strip only handled "/", failing direct `node` execution on Windows paths. Strip "/" or "\". (Same pattern exists in ~10 sibling test files; fixed only the flagged one per minimal scope.) Note: the suggested integration.test.js reference (file:// exact match) isn't Windows-robust either, so used a separator-agnostic regex. - integration.test.js: read-only-key test swallowed initialize() errors silently; now logs them so a real init failure stays visible. - integration.test.js: the create-feed "unavailable" skip regex required a space after "add-?on", so "addon_slug ... is not registered" wasn't caught; dropped the space. ("Feed add-on not active" was already covered by the existing "not active" branch.) Verified: verify:tool-names 49/49 all-match; test:auth 25; test:unit 269; live integration 27; lint:docs green. --- scripts/verify-tool-names.mjs | 21 +++++++++++++++------ test/authentication.test.js | 2 +- test/integration.test.js | 7 +++++-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/scripts/verify-tool-names.mjs b/scripts/verify-tool-names.mjs index 6616b17..d391f98 100644 --- a/scripts/verify-tool-names.mjs +++ b/scripts/verify-tool-names.mjs @@ -49,12 +49,21 @@ let gvDynamic, abilityNames; try { const { definitions } = await loadAbilitiesAsTools(wp); gvDynamic = new Set(definitions.map((d) => d.name)); - const catalog = (await wp.httpClient.request({ - method: 'GET', - baseURL: wp.baseUrl, - url: '/wp-json/wp-abilities/v1/abilities', - })).data; - abilityNames = new Set(catalog.filter((a) => a.name?.startsWith('gk-gravityview/')).map((a) => a.name)); + // The WP Abilities endpoint paginates (default per_page 50), so walk every + // page or gk-gravityview/* names beyond the first page are missed. + abilityNames = new Set(); + for (let page = 1, totalPages = 1; page <= totalPages; page += 1) { + const resp = await wp.httpClient.request({ + method: 'GET', + baseURL: wp.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + params: { per_page: 100, page }, + }); + for (const a of resp.data) { + if (a.name?.startsWith('gk-gravityview/')) abilityNames.add(a.name); + } + totalPages = Number(resp.headers?.['x-wp-totalpages']) || 1; + } } catch (err) { console.error(`✗ Could not load the live abilities catalog from ${wp.baseUrl}`); console.error(` ${err.message}`); diff --git a/test/authentication.test.js b/test/authentication.test.js index 518f6bf..e7393dd 100644 --- a/test/authentication.test.js +++ b/test/authentication.test.js @@ -368,7 +368,7 @@ suite.test('Failure Mode: Should handle malformed OAuth signature', () => { }); // Run tests when executed directly -const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*\//, "")); +const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*[\\/]/, "")); if (isMain) { suite.run().then(results => { process.exit(results.failed > 0 ? 1 : 0); diff --git a/test/integration.test.js b/test/integration.test.js index d66c149..1e09666 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -400,7 +400,7 @@ suite.test('Integration: Create test feed (if MailChimp available)', async () => result = await client.createFeed(feedData); } catch (error) { const msg = error.message || ''; - const unavailable = /table does not exist|missing_table|not installed|not active|invalid add-?on|add-?on .*not (registered|found)/i.test(msg); + const unavailable = /table does not exist|missing_table|not installed|not active|invalid add-?on|add-?on.*not (registered|found)/i.test(msg); if (unavailable) { console.log(` MailChimp feed add-on not available - skipping (${msg})`); return; @@ -763,7 +763,10 @@ suite.test('Security: read-only API key cannot write', async () => { GRAVITY_FORMS_CONSUMER_KEY: roKey, GRAVITY_FORMS_CONSUMER_SECRET: roSecret }); - await roClient.initialize().catch(() => {}); + // initialize() can legitimately fail for a read-only key (its REST-access + // probe may not pass every endpoint); continue to the read/write checks, + // but log rather than swallow so a real init failure stays visible. + await roClient.initialize().catch((e) => console.log(` read-only client init reported: ${e.message} — continuing`)); // Reads must work… (GF /forms returns an ID-keyed object, not an array) const listed = await roClient.listForms({}); From b93bb5a94189322be62afbfc9482db3bbb6a561b Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 20:34:21 -0400 Subject: [PATCH 27/36] test: TDD the review fixes into covered helpers; require TDD in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redid the prior review fixes test-first (RED watched, then GREEN), pulling the logic out of inline test/script bodies into units with real coverage: - test/helpers.js: isMainModule (POSIX+Windows path basename), feedUnavailable (feed add-on/infra "unavailable" detection), settleWithReport (report, don't swallow, a rejected init). New test/helpers.test.js — 13 cases; each bug case was confirmed failing against the old behavior before the fix. - scripts/lib/ability-catalog.mjs: collectAbilityNames() walks every page of the paginated WP Abilities catalog. New test/ability-catalog.test.js — the multi-page case failed against the single-page stub, then passed. - Wired call sites to the helpers (authentication.test.js, integration.test.js, verify-tool-names.mjs), removing the inline copies. No STUB placeholders remain — each was replaced during GREEN. - package.json: test:lib runs the node:test units; added to test:all and prepublishOnly. - AGENTS.md: new "Test-Driven Development (required)" section mandating RED/GREEN/REFACTOR; Extension Patterns now says write the failing test first; repo map + Testing list updated. Verified: test:lib 16, test:unit 269, test:field-ops 131, live integration 27, verify:tool-names 49/49 all-match, lint:docs green, npm pack clean (31 files). --- AGENTS.md | 17 ++++++++- package.json | 5 +-- scripts/lib/ability-catalog.mjs | 29 +++++++++++++++ scripts/verify-tool-names.mjs | 18 ++-------- test/ability-catalog.test.js | 42 ++++++++++++++++++++++ test/authentication.test.js | 4 +-- test/helpers.js | 34 ++++++++++++++++++ test/helpers.test.js | 63 +++++++++++++++++++++++++++++++++ test/integration.test.js | 10 +++--- 9 files changed, 196 insertions(+), 26 deletions(-) create mode 100644 scripts/lib/ability-catalog.mjs create mode 100644 test/ability-catalog.test.js create mode 100644 test/helpers.test.js diff --git a/AGENTS.md b/AGENTS.md index d8f0346..97d0d99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,8 @@ MCP/ │ ├── check-env.js # Environment validation script │ ├── check-docs.mjs # Doc-freshness guard for AGENTS.md (offline; npm run lint:docs) │ ├── verify-tool-names.mjs # Cross-check doc/instruction tool names vs registered tools (needs live site) +│ ├── lib/ +│ │ └── ability-catalog.mjs # collectAbilityNames() — paginated Abilities catalog reader │ ├── stress-abilities.mjs # Synthetic abilities-loader stress/contract test │ ├── setup-test-data.js # Test data seeding │ ├── test-field-ops.js # Field operations smoke test @@ -246,7 +248,7 @@ All GF delete operations (`deleteForm`, `deleteEntry`, `deleteFeed`) check `this 2. **Add the client method** in `gravity-forms-client.js` using `validateAndCall`, returning minimal data. 3. **Add validation** in `config/validation.js` inside `ValidationFactory.validateToolInput()`. 4. **Add the handler route** in the `CallToolRequestSchema` switch in `src/index.js`. -5. **Add tests** in `test/`, importing the source under test as `../src/…` (see `forms.test.js`). +5. **Write the failing test first** (TDD — see Test-Driven Development above), then implement steps 1–4 to make it pass. Tests live in `test/`, importing the source under test as `../src/…` (see `forms.test.js`). ### Adding GravityKit (`gv_*`) Tools @@ -275,6 +277,18 @@ For compound fields (multi-input like address/name), set `storage.type: 'compoun 2. Add the chainable method in `config/validation-chain.js` 3. Use it in validators via `validate('fieldName').newRule()` +## Test-Driven Development (required) + +All development here is **test-first** — features, bug fixes, refactors, behavior changes. The cycle is non-negotiable: + +1. **RED** — write one failing test that pins the intended behavior, and run it to watch it fail *for the right reason*. No production code before this. +2. **GREEN** — write the minimal code to make it pass; keep the rest of the suite green. +3. **REFACTOR** — clean up with the tests staying green. + +A test that passes the first time you run it proves nothing — if you can't point to the RED run, it isn't TDD. Extract logic into a testable unit instead of burying it inline where it can't be exercised (e.g. `feedUnavailable` and `collectAbilityNames` were extracted so their behavior is covered, RED-then-GREEN). Bug fixes start with a failing test that reproduces the bug. + +Pure-function/unit tests use `node:test` and live in `test/` (run via `npm run test:lib`); see Testing. Never wire a fix into the codebase ahead of its failing test. + ## Development ### Setup @@ -344,6 +358,7 @@ Shorthand aliases: `TEST_GF_URL`, `TEST_GF_CONSUMER_KEY`, `TEST_GF_CONSUMER_SECR ```bash npm run test:unit # Unit tests via custom runner +npm run test:lib # node:test unit tests for extracted helpers (TDD) npm run test:auth # Authentication tests npm run test:forms # Forms endpoint tests npm run test:entries # Entries endpoint tests diff --git a/package.json b/package.json index 45bdb61..8ba7e26 100644 --- a/package.json +++ b/package.json @@ -27,11 +27,12 @@ "test:tools": "node test/server-tools.test.js", "test:compact": "node test/compact.test.js", "test:views": "node test/views.test.js", - "test:all": "npm run test:unit && npm run test:field-ops && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm run test:views && npm test", + "test:lib": "node --test test/helpers.test.js test/ability-catalog.test.js", + "test:all": "npm run test:unit && npm run test:field-ops && npm run test:lib && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm run test:views && npm test", "test:coverage": "echo 'Running all tests with coverage analysis' && npm run test:all", "lint:package": "publint", "lint:docs": "node scripts/check-docs.mjs", - "prepublishOnly": "npm run test:unit && npm run test:field-ops && npm run test:field-validation && npm run test:views && npm run lint:package && npm run lint:docs" + "prepublishOnly": "npm run test:unit && npm run test:field-ops && npm run test:lib && npm run test:field-validation && npm run test:views && npm run lint:package && npm run lint:docs" }, "keywords": [ "mcp", diff --git a/scripts/lib/ability-catalog.mjs b/scripts/lib/ability-catalog.mjs new file mode 100644 index 0000000..6253b0f --- /dev/null +++ b/scripts/lib/ability-catalog.mjs @@ -0,0 +1,29 @@ +/** + * Helpers for reading the WordPress Abilities catalog (dev tooling). + */ + +/** + * Collect every ability name with the given prefix from the WP Abilities + * endpoint. The endpoint paginates (default per_page 50), so all pages must + * be walked or names beyond the first page are missed. + * + * @param {object} wpClient - WordPressClient (has baseUrl + httpClient.request) + * @param {{prefix?: string, perPage?: number}} [opts] + * @returns {Promise>} + */ +export async function collectAbilityNames(wpClient, { prefix = 'gk-gravityview/', perPage = 100 } = {}) { + const names = new Set(); + for (let page = 1, totalPages = 1; page <= totalPages; page += 1) { + const resp = await wpClient.httpClient.request({ + method: 'GET', + baseURL: wpClient.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + params: { per_page: perPage, page }, + }); + for (const a of resp.data) { + if (a.name?.startsWith(prefix)) names.add(a.name); + } + totalPages = Number(resp.headers?.['x-wp-totalpages']) || 1; + } + return names; +} diff --git a/scripts/verify-tool-names.mjs b/scripts/verify-tool-names.mjs index d391f98..669bf56 100644 --- a/scripts/verify-tool-names.mjs +++ b/scripts/verify-tool-names.mjs @@ -26,6 +26,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { WordPressClient } from '../src/wp-client.js'; import { loadAbilitiesAsTools } from '../src/abilities/loader.js'; +import { collectAbilityNames } from './lib/ability-catalog.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); @@ -49,21 +50,8 @@ let gvDynamic, abilityNames; try { const { definitions } = await loadAbilitiesAsTools(wp); gvDynamic = new Set(definitions.map((d) => d.name)); - // The WP Abilities endpoint paginates (default per_page 50), so walk every - // page or gk-gravityview/* names beyond the first page are missed. - abilityNames = new Set(); - for (let page = 1, totalPages = 1; page <= totalPages; page += 1) { - const resp = await wp.httpClient.request({ - method: 'GET', - baseURL: wp.baseUrl, - url: '/wp-json/wp-abilities/v1/abilities', - params: { per_page: 100, page }, - }); - for (const a of resp.data) { - if (a.name?.startsWith('gk-gravityview/')) abilityNames.add(a.name); - } - totalPages = Number(resp.headers?.['x-wp-totalpages']) || 1; - } + // Walks every page of the paginated WP Abilities catalog (see ability-catalog.mjs). + abilityNames = await collectAbilityNames(wp); } catch (err) { console.error(`✗ Could not load the live abilities catalog from ${wp.baseUrl}`); console.error(` ${err.message}`); diff --git a/test/ability-catalog.test.js b/test/ability-catalog.test.js new file mode 100644 index 0000000..cec6128 --- /dev/null +++ b/test/ability-catalog.test.js @@ -0,0 +1,42 @@ +/** + * Unit tests for collectAbilityNames (abilities-catalog pagination). + */ + +import test from 'node:test'; +import assert from 'node:assert'; +import { collectAbilityNames } from '../scripts/lib/ability-catalog.mjs'; + +// Mock WordPressClient: serves one array of abilities per page and reports +// the page count via the X-WP-TotalPages header, like the real endpoint. +function mockClient(pages) { + return { + baseUrl: 'http://example.test', + httpClient: { + request: async ({ params }) => ({ + data: pages[(params?.page ?? 1) - 1] ?? [], + headers: { 'x-wp-totalpages': String(pages.length) }, + }), + }, + }; +} + +test('collectAbilityNames: accumulates matching names across ALL pages', async () => { + const client = mockClient([ + [{ name: 'gk-gravityview/view-create' }, { name: 'core/get-site-info' }], + [{ name: 'gk-gravityview/views-scan' }], + ]); + const names = await collectAbilityNames(client); + assert.deepEqual([...names].sort(), ['gk-gravityview/view-create', 'gk-gravityview/views-scan']); +}); + +test('collectAbilityNames: filters by prefix', async () => { + const client = mockClient([[{ name: 'gk-gravityview/view-create' }, { name: 'core/get-site-info' }]]); + const names = await collectAbilityNames(client); + assert.deepEqual([...names], ['gk-gravityview/view-create']); +}); + +test('collectAbilityNames: single page when only one page exists', async () => { + const client = mockClient([[{ name: 'gk-gravityview/layouts-list' }]]); + const names = await collectAbilityNames(client); + assert.deepEqual([...names], ['gk-gravityview/layouts-list']); +}); diff --git a/test/authentication.test.js b/test/authentication.test.js index e7393dd..a26a663 100644 --- a/test/authentication.test.js +++ b/test/authentication.test.js @@ -4,7 +4,7 @@ */ import { AuthManager, BasicAuthHandler, OAuth1Handler, validateRestApiAccess } from '../src/config/auth.js'; -import { TestRunner, TestAssert, MockHttpClient, MockResponse, setupTestEnvironment } from './helpers.js'; +import { TestRunner, TestAssert, MockHttpClient, MockResponse, setupTestEnvironment, isMainModule } from './helpers.js'; const suite = new TestRunner('Authentication Tests'); @@ -368,7 +368,7 @@ suite.test('Failure Mode: Should handle malformed OAuth signature', () => { }); // Run tests when executed directly -const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*[\\/]/, "")); +const isMain = isMainModule(import.meta.url, process.argv[1]); if (isMain) { suite.run().then(results => { process.exit(results.failed > 0 ? 1 : 0); diff --git a/test/helpers.js b/test/helpers.js index 609fba2..f8b7e4e 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -19,6 +19,40 @@ export function generateString(prefix = 'test') { return `${prefix}_${crypto.randomBytes(4).toString('hex')}`; } +// --- Behavior helpers (extracted for testability; see helpers.test.js) --- + +/** + * Whether the running module is the process entrypoint (so a test file run + * directly via `node test/x.test.js` self-executes). + */ +export function isMainModule(importMetaUrl, argv1) { + // Strip up to the last "/" or "\" so it works on POSIX and Windows paths. + return !!argv1 && importMetaUrl.endsWith(argv1.replace(/.*[\\/]/, '')); +} + +/** + * Whether a feed error means the add-on or its infrastructure is unavailable + * (so a feed test should skip), vs a genuine error that must be re-thrown. + */ +export function feedUnavailable(message) { + // No mandatory space after add-?on, so "addon_slug ... is not registered" + // matches; genuine errors (bad meta, validation) don't. + return /table does not exist|missing_table|not installed|not active|invalid add-?on|add-?on.*not (registered|found)/i.test(message || ''); +} + +/** + * Await a promise; on rejection, report (don't swallow) the error and + * continue. Returns the resolved value, or undefined on rejection. + */ +export async function settleWithReport(promise, report) { + try { + return await promise; + } catch (error) { + report(error); + return undefined; + } +} + /** * Generate mock form data */ diff --git a/test/helpers.test.js b/test/helpers.test.js new file mode 100644 index 0000000..2940709 --- /dev/null +++ b/test/helpers.test.js @@ -0,0 +1,63 @@ +/** + * Unit tests for the behavior helpers in helpers.js + * (isMainModule, feedUnavailable, settleWithReport). + */ + +import test from 'node:test'; +import assert from 'node:assert'; +import { isMainModule, feedUnavailable, settleWithReport } from './helpers.js'; + +// --- isMainModule --- + +test('isMainModule: matches a forward-slash argv path', () => { + assert.equal(isMainModule('file:///a/b/auth.test.js', '/a/b/auth.test.js'), true); +}); + +test('isMainModule: matches a Windows backslash argv path', () => { + assert.equal(isMainModule('file:///C:/a/b/auth.test.js', 'C:\\a\\b\\auth.test.js'), true); +}); + +test('isMainModule: false when the basename differs', () => { + assert.equal(isMainModule('file:///a/b/auth.test.js', '/a/b/run.js'), false); +}); + +test('isMainModule: false when argv is missing', () => { + assert.equal(isMainModule('file:///a/b/auth.test.js', undefined), false); +}); + +// --- feedUnavailable --- + +const UNAVAILABLE = [ + 'gf_create_feed failed: The wp_gf_addon_feed table does not exist.', + 'addon_slug gravityformsmailchimp is not registered', + 'Feed add-on not active', + 'The gravityformsmailchimp Add-On is not installed', +]; +for (const msg of UNAVAILABLE) { + test(`feedUnavailable: skips unavailable message — "${msg}"`, () => { + assert.equal(feedUnavailable(msg), true); + }); +} + +const GENUINE = ['Invalid feed meta', 'Validation error: feedName is required', '']; +for (const msg of GENUINE) { + test(`feedUnavailable: re-throws genuine error — "${msg}"`, () => { + assert.equal(feedUnavailable(msg), false); + }); +} + +// --- settleWithReport --- + +test('settleWithReport: reports the error and resolves on rejection', async () => { + let reported = null; + const result = await settleWithReport(Promise.reject(new Error('boom')), (e) => { reported = e.message; }); + assert.equal(reported, 'boom'); + assert.equal(result, undefined); +}); + +test('settleWithReport: returns the value and does not report on success', async () => { + let reported = false; + const result = await settleWithReport(Promise.resolve(42), () => { reported = true; }); + assert.equal(result, 42); + assert.equal(reported, false); +}); diff --git a/test/integration.test.js b/test/integration.test.js index 1e09666..3066297 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -5,7 +5,7 @@ import dotenv from 'dotenv'; import GravityFormsClient from '../src/gravity-forms-client.js'; -import { TestRunner, TestAssert } from './helpers.js'; +import { TestRunner, TestAssert, feedUnavailable, settleWithReport } from './helpers.js'; import { validateRestApiAccess } from '../src/config/auth.js'; // Set test mode to suppress initialization messages to stderr @@ -399,10 +399,8 @@ suite.test('Integration: Create test feed (if MailChimp available)', async () => try { result = await client.createFeed(feedData); } catch (error) { - const msg = error.message || ''; - const unavailable = /table does not exist|missing_table|not installed|not active|invalid add-?on|add-?on.*not (registered|found)/i.test(msg); - if (unavailable) { - console.log(` MailChimp feed add-on not available - skipping (${msg})`); + if (feedUnavailable(error.message)) { + console.log(` MailChimp feed add-on not available - skipping (${error.message})`); return; } throw error; @@ -766,7 +764,7 @@ suite.test('Security: read-only API key cannot write', async () => { // initialize() can legitimately fail for a read-only key (its REST-access // probe may not pass every endpoint); continue to the read/write checks, // but log rather than swallow so a real init failure stays visible. - await roClient.initialize().catch((e) => console.log(` read-only client init reported: ${e.message} — continuing`)); + await settleWithReport(roClient.initialize(), (e) => console.log(` read-only client init reported: ${e.message} — continuing`)); // Reads must work… (GF /forms returns an ID-keyed object, not an array) const listed = await roClient.listForms({}); From d338a2e7a1a8268af6a27d4fad1270fc74653eb9 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 20:37:38 -0400 Subject: [PATCH 28/36] chore: consolidate node:test scripts into one test:node [ci skip] The two node:test suites had near-duplicate scripts (test:field-ops + test:lib). Merge into a single honestly-named test:node listing all six node:test files; update test:all, prepublishOnly, and the AGENTS.md Testing list / TDD section. No test logic changed (147 node:test cases still run). --- AGENTS.md | 4 ++-- package.json | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 97d0d99..963e854 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,7 +287,7 @@ All development here is **test-first** — features, bug fixes, refactors, behav A test that passes the first time you run it proves nothing — if you can't point to the RED run, it isn't TDD. Extract logic into a testable unit instead of burying it inline where it can't be exercised (e.g. `feedUnavailable` and `collectAbilityNames` were extracted so their behavior is covered, RED-then-GREEN). Bug fixes start with a failing test that reproduces the bug. -Pure-function/unit tests use `node:test` and live in `test/` (run via `npm run test:lib`); see Testing. Never wire a fix into the codebase ahead of its failing test. +Pure-function/unit tests use `node:test` and live in `test/` (run via `npm run test:node`); see Testing. Never wire a fix into the codebase ahead of its failing test. ## Development @@ -358,7 +358,7 @@ Shorthand aliases: `TEST_GF_URL`, `TEST_GF_CONSUMER_KEY`, `TEST_GF_CONSUMER_SECR ```bash npm run test:unit # Unit tests via custom runner -npm run test:lib # node:test unit tests for extracted helpers (TDD) +npm run test:node # node:test unit suites (field ops, helpers, ability-catalog) npm run test:auth # Authentication tests npm run test:forms # Forms endpoint tests npm run test:entries # Entries endpoint tests diff --git a/package.json b/package.json index 8ba7e26..d46d400 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "verify:tool-names": "node scripts/verify-tool-names.mjs", "test": "node test/integration.test.js", "test:unit": "node test/run.js", - "test:field-ops": "node --test test/field-manager.test.js test/field-registry.test.js test/field-dependencies.test.js test/field-positioner.test.js", + "test:node": "node --test test/field-manager.test.js test/field-registry.test.js test/field-dependencies.test.js test/field-positioner.test.js test/helpers.test.js test/ability-catalog.test.js", "test:auth": "node test/authentication.test.js", "test:forms": "node test/forms.test.js", "test:entries": "node test/entries.test.js", @@ -27,12 +27,11 @@ "test:tools": "node test/server-tools.test.js", "test:compact": "node test/compact.test.js", "test:views": "node test/views.test.js", - "test:lib": "node --test test/helpers.test.js test/ability-catalog.test.js", - "test:all": "npm run test:unit && npm run test:field-ops && npm run test:lib && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm run test:views && npm test", + "test:all": "npm run test:unit && npm run test:node && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm run test:views && npm test", "test:coverage": "echo 'Running all tests with coverage analysis' && npm run test:all", "lint:package": "publint", "lint:docs": "node scripts/check-docs.mjs", - "prepublishOnly": "npm run test:unit && npm run test:field-ops && npm run test:lib && npm run test:field-validation && npm run test:views && npm run lint:package && npm run lint:docs" + "prepublishOnly": "npm run test:unit && npm run test:node && npm run test:field-validation && npm run test:views && npm run lint:package && npm run lint:docs" }, "keywords": [ "mcp", From d2a1e26e02e660929090448a5f281e30e1ba3665 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 20:53:07 -0400 Subject: [PATCH 29/36] fix: review findings (TDD for behavioral changes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each finding against current code; fixed the still-valid ones. Behavioral (test-first, RED watched then GREEN): - field-manager: layoutGridColumnSpan now validated with Number()+ Number.isInteger() (was parseInt/isFinite), so "6.5"/"6wide"/floats/ empty/whitespace are dropped instead of coerced. (test/field-manager.test.js) - view-validator: field_id must be a finite number or non-empty string — rejects false/objects/arrays/whitespace that previously passed. Kept numeric support (it's coerced via String(item.field_id)); the reviewer's "require string type" would have broken numeric ids. (test/views.test.js) - User-Agent: single-sourced via new src/version.js (USER_AGENT from package.json). GravityFormsClient said 2.1.0, WordPressClient 2.1.1; now both match the package version. (test/user-agent.test.js) Non-behavioral (TDD-exempt): - inspector-client: fixed four flush-left lines (removeViewField, deleteGridRow comment, removeSearchField, removeViewWidget). - .env.example: alphabetized GRAVITY_FORMS_MAX_RETRIES before _TIMEOUT. Skipped — already addressed earlier: - demo-abilities.mjs absolute imports + /tmp header (fixed in ded240c). - AGENTS.md "28 tool descriptions" (the AGENTS.md rewrite dropped the count). Wiring: test:node now includes user-agent.test.js; version.js added to the AGENTS.md repo map. Verified: test:node 151, test:views 27, test:unit 269, live integration 27, verify:tool-names 49/49, lint:docs + publint green. --- .env.example | 2 +- AGENTS.md | 1 + package.json | 2 +- src/field-operations/field-manager.js | 9 ++++-- src/gravity-forms-client.js | 3 +- src/gravityview/inspector-client.js | 8 +++--- src/gravityview/view-validator.js | 7 ++++- src/version.js | 14 ++++++++++ src/wp-client.js | 3 +- test/field-manager.test.js | 13 +++++++++ test/user-agent.test.js | 40 +++++++++++++++++++++++++++ test/views.test.js | 16 +++++++++++ 12 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 src/version.js create mode 100644 test/user-agent.test.js diff --git a/.env.example b/.env.example index 8bbf4a0..944931c 100644 --- a/.env.example +++ b/.env.example @@ -54,8 +54,8 @@ GRAVITY_FORMS_ALLOW_DELETE=false # ============================================================ # CONNECTION # ============================================================ -GRAVITY_FORMS_TIMEOUT=30000 GRAVITY_FORMS_MAX_RETRIES=3 +GRAVITY_FORMS_TIMEOUT=30000 # ============================================================ # DEBUG diff --git a/AGENTS.md b/AGENTS.md index 963e854..cc9ddca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ MCP/ │ ├── index.js # Server bootstrap, two-plane init, tool registration, handler routing │ ├── gravity-forms-client.js # GravityFormsClient: GF REST HTTP client, all gf_* API methods │ ├── wp-client.js # WordPressClient: product-agnostic authenticated WP transport (Plane B) +│ ├── version.js # VERSION + USER_AGENT, single-sourced from package.json │ ├── abilities/ │ │ └── loader.js # loadAbilitiesAsTools() — turns the live Abilities catalog into gv_* tools │ ├── gravityview/ # GravityView test/demo harness (NOT runtime — gv_* come from abilities/) diff --git a/package.json b/package.json index d46d400..4aa0af5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "verify:tool-names": "node scripts/verify-tool-names.mjs", "test": "node test/integration.test.js", "test:unit": "node test/run.js", - "test:node": "node --test test/field-manager.test.js test/field-registry.test.js test/field-dependencies.test.js test/field-positioner.test.js test/helpers.test.js test/ability-catalog.test.js", + "test:node": "node --test test/field-manager.test.js test/field-registry.test.js test/field-dependencies.test.js test/field-positioner.test.js test/helpers.test.js test/ability-catalog.test.js test/user-agent.test.js", "test:auth": "node test/authentication.test.js", "test:forms": "node test/forms.test.js", "test:entries": "node test/entries.test.js", diff --git a/src/field-operations/field-manager.js b/src/field-operations/field-manager.js index 2d68248..a82c541 100644 --- a/src/field-operations/field-manager.js +++ b/src/field-operations/field-manager.js @@ -225,8 +225,13 @@ export class FieldManager { */ normalizeLayoutProperties(field, formId) { if (typeof field.layoutGridColumnSpan !== 'undefined') { - const span = parseInt(field.layoutGridColumnSpan, 10); - if (Number.isFinite(span)) { + const raw = field.layoutGridColumnSpan; + // Accept only true integers / integer strings — Number() (not parseInt) + // so "6.5" and "6wide" become NaN instead of being truncated to 6, and + // empty/whitespace strings are rejected rather than coerced to 0. + const numeric = typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== ''); + const span = numeric ? Number(raw) : NaN; + if (Number.isInteger(span)) { field.layoutGridColumnSpan = Math.min(12, Math.max(1, span)); } else { delete field.layoutGridColumnSpan; diff --git a/src/gravity-forms-client.js b/src/gravity-forms-client.js index 655740a..901fb17 100644 --- a/src/gravity-forms-client.js +++ b/src/gravity-forms-client.js @@ -13,6 +13,7 @@ import { sanitizeUrl, sanitizeHeaders } from './utils/sanitize.js'; import { generateCompoundInputs } from './field-definitions/field-registry.js'; import { testConfig } from './config/test-config.js'; import { resourceMutex } from './utils/mutex.js'; +import { USER_AGENT } from './version.js'; export class GravityFormsClient { constructor(config) { @@ -25,7 +26,7 @@ export class GravityFormsClient { baseURL: this.baseURL, timeout: parseInt(config.GRAVITY_FORMS_TIMEOUT) || 30000, headers: { - 'User-Agent': 'GravityKit-MCP/2.1.0', + 'User-Agent': USER_AGENT, 'Accept': 'application/json' }, // Serialize query params as explicit bracket-index pairs diff --git a/src/gravityview/inspector-client.js b/src/gravityview/inspector-client.js index 7a0f764..ae35c0c 100644 --- a/src/gravityview/inspector-client.js +++ b/src/gravityview/inspector-client.js @@ -342,7 +342,7 @@ export class GravityViewInspectorClient extends WordPressClient { async removeViewField({ id, area, slot, ifMatch } = {}) { requireViewId(id); requireAreaSlot(area, slot); -const response = await this.httpClient.delete( + const response = await this.httpClient.delete( `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}`, this.ifMatchHeaders(id, ifMatch) ); @@ -399,7 +399,7 @@ const response = await this.httpClient.delete( async deleteGridRow({ id, surface, row_uid, ifMatch } = {}) { requireViewId(id); if (!row_uid) throw new Error('row_uid is required.'); -// axios.delete requires `data` inside the config to send a body. + // axios.delete requires `data` inside the config to send a body. const config = this.ifMatchHeaders(id, ifMatch) || {}; if (surface) config.data = { surface }; const response = await this.httpClient.delete( @@ -488,7 +488,7 @@ const response = await this.httpClient.delete( if (!widget_area || !widget_slot || !position || !search_slot) { throw new Error('widget_area, widget_slot, position, and search_slot are required.'); } -const config = this.ifMatchHeaders(id, ifMatch) || {}; + const config = this.ifMatchHeaders(id, ifMatch) || {}; config.data = { widget_area, widget_slot, position }; const response = await this.httpClient.delete( `/views/${id}/search-fields/${encodeURIComponent(search_slot)}`, @@ -534,7 +534,7 @@ const config = this.ifMatchHeaders(id, ifMatch) || {}; async removeViewWidget({ id, area, slot, ifMatch } = {}) { requireViewId(id); requireAreaSlot(area, slot); -const response = await this.httpClient.delete( + const response = await this.httpClient.delete( `/views/${id}/widgets/${encodeURIComponent(area)}/${encodeURIComponent(slot)}`, this.ifMatchHeaders(id, ifMatch) ); diff --git a/src/gravityview/view-validator.js b/src/gravityview/view-validator.js index f289579..ff76bd0 100644 --- a/src/gravityview/view-validator.js +++ b/src/gravityview/view-validator.js @@ -101,7 +101,12 @@ export class ViewValidator { if (!item || typeof item !== 'object') { throw new Error(`${label}["${area}"][${idx}] must be an object.`); } - if (!('field_id' in item) || item.field_id === '' || item.field_id === null) { + // field_id must be a finite number or a non-empty (non-whitespace) + // string — it's later coerced via String(item.field_id). This rejects + // false, objects, arrays, and whitespace-only values. + const fid = item.field_id; + const validFieldId = (typeof fid === 'number' && Number.isFinite(fid)) || (typeof fid === 'string' && fid.trim() !== ''); + if (!validFieldId) { throw new Error(`${label}["${area}"][${idx}] is missing required key "field_id".`); } if (item.slot !== undefined && typeof item.slot !== 'string') { diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..a2f71ac --- /dev/null +++ b/src/version.js @@ -0,0 +1,14 @@ +/** + * Single source of truth for the package version and User-Agent. + * Read from package.json so the clients never drift from the released version. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const pkg = JSON.parse( + readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8') +); + +export const VERSION = pkg.version; +export const USER_AGENT = `GravityKit-MCP/${VERSION}`; diff --git a/src/wp-client.js b/src/wp-client.js index 12c0cfb..198d027 100644 --- a/src/wp-client.js +++ b/src/wp-client.js @@ -17,6 +17,7 @@ import axios from 'axios'; import https from 'https'; +import { USER_AGENT } from './version.js'; export class WordPressClient { constructor(config) { @@ -80,7 +81,7 @@ export class WordPressClient { baseURL, timeout: this.timeoutMs, headers: { - 'User-Agent': 'GravityKit-MCP/2.1.1', + 'User-Agent': USER_AGENT, 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': this.basicAuth, diff --git a/test/field-manager.test.js b/test/field-manager.test.js index 8b14c97..88a900c 100644 --- a/test/field-manager.test.js +++ b/test/field-manager.test.js @@ -371,6 +371,19 @@ test('FieldManager - normalizeLayoutProperties', async (t) => { assert.strictEqual('layoutGridColumnSpan' in field, false); }); + await t.test('layoutGridColumnSpan drops floats and partial-numeric strings', () => { + const dropped = (value) => 'layoutGridColumnSpan' in manager.normalizeLayoutProperties({ layoutGridColumnSpan: value }, 1) === false; + assert.ok(dropped('6wide'), '"6wide" should be dropped, not coerced to 6'); + assert.ok(dropped('6.5'), '"6.5" should be dropped'); + assert.ok(dropped(6.5), '6.5 (float) should be dropped'); + assert.ok(dropped(''), 'empty string should be dropped'); + assert.ok(dropped(' '), 'whitespace-only string should be dropped'); + assert.ok(dropped(true), 'boolean should be dropped'); + // Valid integers (and integer strings) are still kept. + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGridColumnSpan: 8 }, 1).layoutGridColumnSpan, 8); + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGridColumnSpan: ' 7 ' }, 1).layoutGridColumnSpan, 7); + }); + await t.test('empty and missing layoutGroupId are left alone', () => { assert.strictEqual(manager.normalizeLayoutProperties({ layoutGroupId: '' }, 1).layoutGroupId, ''); assert.strictEqual('layoutGroupId' in manager.normalizeLayoutProperties({}, 1), false); diff --git a/test/user-agent.test.js b/test/user-agent.test.js new file mode 100644 index 0000000..2e429d9 --- /dev/null +++ b/test/user-agent.test.js @@ -0,0 +1,40 @@ +/** + * Both API clients must report the same User-Agent, single-sourced from + * package.json (no per-client hardcoded version that can drift). + */ + +import test from 'node:test'; +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import GravityFormsClient from '../src/gravity-forms-client.js'; +import { WordPressClient } from '../src/wp-client.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); +const expected = `GravityKit-MCP/${pkg.version}`; + +const uaOf = (client) => client.httpClient.defaults.headers['User-Agent']; +const gfClient = () => new GravityFormsClient({ + GRAVITY_FORMS_BASE_URL: 'https://example.test', + GRAVITY_FORMS_CONSUMER_KEY: 'k', + GRAVITY_FORMS_CONSUMER_SECRET: 's', +}); +const wpClient = () => new WordPressClient({ + GRAVITYKIT_WP_URL: 'https://example.test', + GRAVITYKIT_WP_USERNAME: 'u', + GRAVITYKIT_WP_APP_PASSWORD: 'p', +}); + +test('GravityFormsClient User-Agent matches package version', () => { + assert.equal(uaOf(gfClient()), expected); +}); + +test('WordPressClient User-Agent matches package version', () => { + assert.equal(uaOf(wpClient()), expected); +}); + +test('both clients agree on the User-Agent', () => { + assert.equal(uaOf(gfClient()), uaOf(wpClient())); +}); diff --git a/test/views.test.js b/test/views.test.js index 8e1a6f8..b76433e 100644 --- a/test/views.test.js +++ b/test/views.test.js @@ -322,6 +322,22 @@ suite.test('Validator: rejects field entries missing field_id', () => { ); }); +suite.test('Validator: rejects non-string/non-number field_id (false, object, array, whitespace)', () => { + const v = new ViewValidator(client); + for (const bad of [false, {}, [], ' ', true]) { + TestAssert.throws( + () => v.validateApplyPayload({ fields: { 'directory_list-title': [{ field_id: bad }] } }), + 'field_id' + ); + } +}); + +suite.test('Validator: accepts a numeric field_id', () => { + const v = new ViewValidator(client); + // field_id is later coerced via String(item.field_id) — numbers are valid. + v.validateApplyPayload({ fields: { 'directory_list-title': [{ field_id: 1 }] } }); +}); + suite.test('Validator: validateAgainstSchemas rejects unknown setting keys', async () => { // Fake schema for the "custom" field type. client.getFieldTypeSchema = async () => ({ From 04911f5d172a7a92ad6cec60dcbd7b5f21b99230 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 20:59:43 -0400 Subject: [PATCH 30/36] docs(readme): document the GravityKit gv_* tools plane [ci skip] README's Features + Available Tools were Gravity-Forms-only. Add a Features bullet and a 'GravityKit Products (gv_*, dynamic)' section covering the runtime-generated GravityView tools and gk_reload_abilities. Verified all README tool names resolve against the live catalog (28 referenced, 0 unknown). --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 0a33359..3d7f43e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit - **Advanced Search**: Complex filtering and searching capabilities for entries - **Form Submissions**: Full submission workflow with validation - **Add-on Integration**: Manage feeds for MailChimp, Stripe, PayPal, and more +- **GravityKit Products**: GravityView View authoring (and more) via `gv_*` tools auto-generated from the site's Foundation abilities catalog - **Type-Safe**: Comprehensive validation for all operations - **Battle-Tested**: Extensive test suite with real-world scenarios @@ -124,6 +125,10 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit - `gf_patch_feed` - Partially update feed properties - `gf_delete_feed` - Delete add-on feeds +### GravityKit Products (`gv_*`, dynamic) + +When [GravityKit Foundation](https://www.gravitykit.com) is active on the connected site, additional `gv_*` tools are generated at runtime from its Abilities catalog — so the exact set depends on the installed GravityKit products and versions. **GravityView** is supported today: View lifecycle (`gv_view_create`, `gv_view_config_apply`, `gv_view_delete`), field/widget/search/grid editing, and discovery (`gv_layouts_list`, `gv_field_type_schema_get`, …). Use the `gv_*_list` discovery tools to see what's available on your site, and `gk_reload_abilities` to reload the catalog after activating or updating GravityKit products. + ## Usage Examples ### Search Entries From a93a6a9c5033154adb7d3d5c03b3891b910a40fd Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 21:06:05 -0400 Subject: [PATCH 31/36] docs: stop conflating GravityKit with the gv_* prefix [ci skip] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gv_* is GravityView's prefix specifically; the GravityKit plane is product-agnostic — each add-on registers tools under its own server-owned prefix (that's why the cross-product reload tool is gk_reload_abilities, not gv_). Reframe the plane as 'GravityKit (dynamic)' in README + AGENTS.md, with GravityView noted as the first product (prefix gv_*). --- AGENTS.md | 16 ++++++++-------- README.md | 40 ++++++++++++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc9ddca..8df6f27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,9 @@ This is the single canonical doc for the project (agents and humans). `CLAUDE.md **What this is:** A Node.js MCP (Model Context Protocol) server with two independent capability planes: - **Plane A — Gravity Forms (`gf_*`), primary.** 26 static tools wrapping the Gravity Forms REST API v2 (forms, entries, feeds, notifications, submissions, field filters, results, and intelligent field management). Always available when Gravity Forms REST credentials work — on any Gravity Forms site. -- **Plane B — GravityKit (`gv_*`), secondary.** Tools generated at runtime from the connected site's GravityKit Foundation Abilities catalog. They appear only when Foundation is active. GravityView is the only GravityKit product wired up so far (View authoring, fields, widgets, search, layouts). The plane is product-agnostic: any GravityKit product that registers Foundation abilities shows up automatically. +- **Plane B — GravityKit, secondary.** Tools generated at runtime from the connected site's GravityKit Foundation Abilities catalog; each product registers tools under its own server-owned prefix. They appear only when Foundation is active. GravityView is the only product wired up so far, using the `gv_*` prefix (View authoring, fields, widgets, search, layouts). The plane is product-agnostic: any GravityKit product that registers Foundation abilities shows up automatically under its own prefix. -The two planes are independent: a GF-only site gets the full `gf_*` surface with no abilities; a GravityKit site without GF REST keys still gets `gv_*`. +The two planes are independent: a GF-only site gets the full `gf_*` surface with no abilities; a GravityKit site without GF REST keys still gets its GravityKit tools. **Main entry point:** `src/index.js` **Architecture style:** MCP SDK server with stdio transport, one HTTP client per plane, composable validation @@ -39,7 +39,7 @@ MCP/ │ ├── wp-client.js # WordPressClient: product-agnostic authenticated WP transport (Plane B) │ ├── version.js # VERSION + USER_AGENT, single-sourced from package.json │ ├── abilities/ -│ │ └── loader.js # loadAbilitiesAsTools() — turns the live Abilities catalog into gv_* tools +│ │ └── loader.js # loadAbilitiesAsTools() — turns the live Abilities catalog into product tools (GravityView → gv_*) │ ├── gravityview/ # GravityView test/demo harness (NOT runtime — gv_* come from abilities/) │ │ ├── inspector-client.js # Client for /wp-json/gravityview/v1 (only when DOING_GRAVITYVIEW_TESTS) │ │ └── view-validator.js # Client-side structural + schema-aware validation for the inspector @@ -95,7 +95,7 @@ MCP/ The server registers tools from two independent sources, initialized separately so a failure in one never blocks the other: - **Plane A — Gravity Forms (`gf_*`).** Static tool definitions in `src/index.js` (`GF_TOOL_DEFINITIONS`) plus the field tools from `src/field-operations/index.js` (`fieldOperationTools`). Backed by `GravityFormsClient` against the GF REST API v2. 26 tools, always present once GF credentials validate. -- **Plane B — GravityKit (`gv_*`).** Generated at runtime by `src/abilities/loader.js` from the connected site's Abilities catalog, backed by `WordPressClient`. The catalog is fetched in the background after startup; tools appear once it loads (the server advertises `tools.listChanged`). A single built-in tool, `gk_reload_abilities`, forces a re-fetch. +- **Plane B — GravityKit.** Generated at runtime by `src/abilities/loader.js` from the connected site's Abilities catalog, backed by `WordPressClient`; each product's tools carry its own prefix (GravityView → `gv_*`). The catalog is fetched in the background after startup; tools appear once it loads (the server advertises `tools.listChanged`). A single built-in tool, `gk_reload_abilities`, forces a re-fetch. ### Initialization Flow @@ -111,7 +111,7 @@ The server registers tools from two independent sources, initialized separately **WordPressClient** (`wp-client.js`): Product-agnostic authenticated WordPress transport for Plane B. The abilities loader rides it to reach the Foundation catalog (`/wp-json/gravitykit/v1/...`) and the WP core Abilities API (`/wp-json/wp-abilities/v1/...`). Auth is a WordPress Application Password via HTTP Basic; when `GRAVITYKIT_WP_*` creds aren't set it falls back to `GRAVITY_FORMS_CONSUMER_KEY`/`SECRET` (commonly the same WP user + app password). -**Abilities loader** (`abilities/loader.js`): `loadAbilitiesAsTools(wpClient)` builds `gv_*` tool definitions + handlers from the live catalog. Source-preference chain: (1) Foundation catalog `/wp-json/gravitykit/v1/abilities` (server-filtered to GravityKit, server-owned tool names), (2) WP core catalog `/wp-json/wp-abilities/v1/abilities` (filtered client-side on Foundation's stamped `meta.gk_registered_by`), (3) throw if neither is reachable (caller leaves `gv_*` unregistered and retries — self-healing). **Tool names are owned by the server** via each ability's `mcp_tool_name` (from the product's `mcp_prefix`, or the full product slug); abilities without one are skipped with a warning rather than client-invented. Handlers execute abilities at `/wp-abilities/v1/abilities/{name}/run` with the HTTP method derived from annotations (`readonly` → GET, `destructive`+`idempotent` → DELETE, else POST). +**Abilities loader** (`abilities/loader.js`): `loadAbilitiesAsTools(wpClient)` builds the GravityKit product tool definitions + handlers from the live catalog (GravityView's carry the `gv_*` prefix). Source-preference chain: (1) Foundation catalog `/wp-json/gravitykit/v1/abilities` (server-filtered to GravityKit, server-owned tool names), (2) WP core catalog `/wp-json/wp-abilities/v1/abilities` (filtered client-side on Foundation's stamped `meta.gk_registered_by`), (3) throw if neither is reachable (caller leaves `gv_*` unregistered and retries — self-healing). **Tool names are owned by the server** via each ability's `mcp_tool_name` (from the product's `mcp_prefix`, or the full product slug); abilities without one are skipped with a warning rather than client-invented. Handlers execute abilities at `/wp-abilities/v1/abilities/{name}/run` with the HTTP method derived from annotations (`readonly` → GET, `destructive`+`idempotent` → DELETE, else POST). **GravityView harness** (`gravityview/inspector-client.js`, `view-validator.js`): The Inspector client and validator target `/wp-json/gravityview/v1` routes that exist only when `DOING_GRAVITYVIEW_TESTS` is defined server-side. They are the integration-test and demo harness — **not** a runtime dependency. Runtime `gv_*` tools come from the abilities loader. @@ -161,7 +161,7 @@ Responses are optimized for minimal token usage: | Utilities | `gf_get_field_filters`, `gf_get_results` | `getFieldFilters`, `getResults` | | Field Ops | `gf_add_field`, `gf_update_field`, `gf_delete_field`, `gf_list_field_types` | via `fieldOperationHandlers` → `FieldManager` | -**Plane B — GravityKit (`gv_*`), dynamic.** Generated from the catalog, so the exact set depends on the connected site's GravityKit products and versions — discover at runtime, don't hard-code. GravityView currently contributes tool families for View lifecycle (`gv_view_create`, `gv_view_config_apply`, `gv_view_delete`, …), fields (`gv_view_field_add`/`patch`/`move`/`remove`), grid rows, widgets, search fields, and discovery/schema (`gv_layouts_list`, `gv_widgets_list`, `gv_field_type_schema_get`, `gv_available_fields_get`, …). Plus the built-in `gk_reload_abilities`. Use the `gv_*_list` discovery tools and `gv_field_type_schema_get` to introspect what's available; the server `instructions` string documents the GravityView authoring flow. To re-verify that prose tool names still match the live catalog, run `npm run verify:tool-names` (see Releasing). +**Plane B — GravityKit, dynamic.** Generated from the catalog, so the exact set depends on the connected site's GravityKit products and versions — each product under its own prefix; discover at runtime, don't hard-code. GravityView (prefix `gv_*`) currently contributes tool families for View lifecycle (`gv_view_create`, `gv_view_config_apply`, `gv_view_delete`, …), fields (`gv_view_field_add`/`patch`/`move`/`remove`), grid rows, widgets, search fields, and discovery/schema (`gv_layouts_list`, `gv_widgets_list`, `gv_field_type_schema_get`, `gv_available_fields_get`, …). Plus the built-in `gk_reload_abilities`. Use the `gv_*_list` discovery tools and `gv_field_type_schema_get` to introspect what's available; the server `instructions` string documents the GravityView authoring flow. To re-verify that prose tool names still match the live catalog, run `npm run verify:tool-names` (see Releasing). ### Response Shapes @@ -251,9 +251,9 @@ All GF delete operations (`deleteForm`, `deleteEntry`, `deleteFeed`) check `this 4. **Add the handler route** in the `CallToolRequestSchema` switch in `src/index.js`. 5. **Write the failing test first** (TDD — see Test-Driven Development above), then implement steps 1–4 to make it pass. Tests live in `test/`, importing the source under test as `../src/…` (see `forms.test.js`). -### Adding GravityKit (`gv_*`) Tools +### Adding GravityKit Product Tools -`gv_*` tools are **not** defined in this repo — they come from the connected site's Foundation Abilities catalog. To add or change them, register/modify abilities in the relevant GravityKit product (the server stamps each ability's `mcp_tool_name`); the loader picks them up automatically. After a catalog change, run `gk_reload_abilities` (live) or `npm run verify:tool-names` to confirm names. +GravityKit product tools (e.g. GravityView's `gv_*`) are **not** defined in this repo — they come from the connected site's Foundation Abilities catalog. To add or change them, register/modify abilities in the relevant GravityKit product (the server stamps each ability's `mcp_tool_name`); the loader picks them up automatically. After a catalog change, run `gk_reload_abilities` (live) or `npm run verify:tool-names` to confirm names. ### Adding a New Field Type to the Registry diff --git a/README.md b/README.md index 3d7f43e..0913ae9 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit ## Features -- **Comprehensive API Coverage**: Gravity Forms API endpoints +- **Full Gravity Forms Coverage**: 26 tools across forms, entries, feeds, notifications, submissions, and field management - **Smart Field Management**: Intelligent field operations with dependency tracking - **Advanced Search**: Complex filtering and searching capabilities for entries - **Form Submissions**: Full submission workflow with validation - **Add-on Integration**: Manage feeds for MailChimp, Stripe, PayPal, and more -- **GravityKit Products**: GravityView View authoring (and more) via `gv_*` tools auto-generated from the site's Foundation abilities catalog +- **GravityKit Products**: dynamic tools auto-generated from the site's Foundation abilities catalog — each add-on under its own prefix (GravityView's View authoring is first, using `gv_*`) - **Type-Safe**: Comprehensive validation for all operations - **Battle-Tested**: Extensive test suite with real-world scenarios @@ -92,6 +92,8 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit ## Available Tools +Two planes: **Gravity Forms** (`gf_*`) — 26 tools, always available — and **GravityKit** — dynamic tools generated from the Foundation catalog when it's active, where each add-on registers tools under its own prefix (GravityView uses `gv_*`). The `gk_reload_abilities` tool reloads the GravityKit catalog. + ### Forms (6 tools) - `gf_list_forms` - List forms with filtering and pagination - `gf_get_form` - Get complete form configuration @@ -125,9 +127,16 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit - `gf_patch_feed` - Partially update feed properties - `gf_delete_feed` - Delete add-on feeds -### GravityKit Products (`gv_*`, dynamic) +### Notifications (1 tool) +- `gf_send_notifications` - Send a form's notifications for an entry + +### Utilities (2 tools) +- `gf_get_field_filters` - List the available field filters for a form +- `gf_get_results` - Get aggregated results for Quiz/Poll/Survey forms -When [GravityKit Foundation](https://www.gravitykit.com) is active on the connected site, additional `gv_*` tools are generated at runtime from its Abilities catalog — so the exact set depends on the installed GravityKit products and versions. **GravityView** is supported today: View lifecycle (`gv_view_create`, `gv_view_config_apply`, `gv_view_delete`), field/widget/search/grid editing, and discovery (`gv_layouts_list`, `gv_field_type_schema_get`, …). Use the `gv_*_list` discovery tools to see what's available on your site, and `gk_reload_abilities` to reload the catalog after activating or updating GravityKit products. +### GravityKit Products (dynamic) + +When [GravityKit Foundation](https://www.gravitykit.com) is active on the connected site, additional tools are generated at runtime from its Abilities catalog — each GravityKit add-on under its own server-assigned prefix, so the exact set depends on the installed products and versions. **GravityView** is supported today, using the `gv_*` prefix: View lifecycle (`gv_view_create`, `gv_view_config_apply`, `gv_view_delete`), field/widget/search/grid editing, and discovery (`gv_layouts_list`, `gv_field_type_schema_get`, …). Use the `gv_*_list` discovery tools to see what's available on your site, and `gk_reload_abilities` to reload the catalog after activating or updating GravityKit products. ## Usage Examples @@ -183,6 +192,16 @@ await mcp.call('gf_submit_form_data', { - `GRAVITY_FORMS_DEBUG=false` - Enable debug logging - `GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=false` - Allow self-signed SSL certificates (local dev only) +### GravityKit Product Tools + +The GravityKit product tools reach the same site over the WordPress REST Abilities API, so on a single install **no extra configuration is needed** — they reuse your `GRAVITY_FORMS_*` credentials (a WordPress username + application password). Override only if the WordPress root differs from the Gravity Forms URL, or to use a separate credential: + +- `GRAVITYKIT_WP_URL` - WordPress site URL (defaults to `GRAVITY_FORMS_BASE_URL`) +- `GRAVITYKIT_WP_USERNAME` - WordPress username (defaults to `GRAVITY_FORMS_CONSUMER_KEY`) +- `GRAVITYKIT_WP_APP_PASSWORD` - Application password (defaults to `GRAVITY_FORMS_CONSUMER_SECRET`) + +These tools appear only when GravityKit Foundation is active on the connected site. + ### Authentication Flow The client picks the right transport from the shape of your credentials — you normally don't configure anything: @@ -275,15 +294,16 @@ The server includes multiple safety mechanisms to prevent accidental production ## Testing ```bash -# Run all tests +# Run everything (offline suites + live integration) npm run test:all -# Run specific test suites -npm run test:forms -npm run test:entries -npm run test:field-operations +# Offline suites +npm run test:unit # custom-runner unit tests +npm run test:node # node:test units (field ops, helpers, ability catalog, …) +npm run test:views # GravityView inspector / validator +npm run test:forms # per-endpoint suites: also test:entries, test:feeds, test:submissions, … -# Run with live API (requires credentials) +# Live integration (requires test credentials) npm test ``` From 118ecf568ba81b32fc0300a70c082752dbe8574f Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 21:15:41 -0400 Subject: [PATCH 32/36] docs(readme): npx-based install and client config [ci skip] Replace the clone + .env + 'node /path/to/MCP/src/index.js' setup with the published-package flow: 'npx -y @gravitykit/mcp' in the MCP client's command, credentials in the client's env block. Drops the clone/npm-install steps from Quick Start (local-checkout dev still covered under Contributing); notes key- pair, self-signed, and version-pin options; Configuration + Troubleshooting now point at the env block (npx) or .env (clone). --- README.md | 60 +++++++++++++++++-------------------------------------- 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 0913ae9..68cbac2 100644 --- a/README.md +++ b/README.md @@ -26,22 +26,12 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit ### Installation -1. **Clone the repository** - ```bash - git clone https://github.com/GravityKit/MCP.git - cd MCP - npm install - ``` - -2. **Set up environment** - ```bash - cp .env.example .env - ``` +No clone or `npm install` needed — `npx` runs the published package on demand. (To run from a local checkout for development, see [Contributing](#contributing).) -3. **Enable the Gravity Forms REST API** (one-time, required for any credential type): +1. **Enable the Gravity Forms REST API** (one-time, required for any credential type): - Go to **Forms → Settings → REST API** and check **Enable access to the API** — Gravity Forms doesn't register its REST routes without it. -4. **Create credentials** in WordPress (pick one): +2. **Create credentials** in WordPress (pick one): **Application password (recommended):** - Go to **Users → Profile → Application Passwords** @@ -52,43 +42,26 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit - On the same **Forms → Settings → REST API** screen, click **Add Key** - Choose the user and permission level, then save the Consumer Key (`ck_…`) and Secret (`cs_…`) -5. **Configure credentials** in `.env`: - ```env - GRAVITY_FORMS_BASE_URL=https://yoursite.com - - # Application password: - GRAVITY_FORMS_CONSUMER_KEY=your_wp_username - GRAVITY_FORMS_CONSUMER_SECRET="xxxx xxxx xxxx xxxx xxxx xxxx" - - # …or a Gravity Forms API key: - # GRAVITY_FORMS_CONSUMER_KEY=ck_your_key - # GRAVITY_FORMS_CONSUMER_SECRET=cs_your_secret - ``` - - **For local development** (Laravel Valet, MAMP, etc.): - ```env - # Add this line if using self-signed certificates - GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true - ``` - -6. **Add to Claude Desktop** - - Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: +3. **Add to your MCP client.** For Claude Desktop, edit `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { "mcpServers": { "gravitykit-mcp": { - "command": "node", - "args": ["/path/to/MCP/src/index.js"], + "command": "npx", + "args": ["-y", "@gravitykit/mcp"], "env": { + "GRAVITY_FORMS_BASE_URL": "https://yoursite.com", "GRAVITY_FORMS_CONSUMER_KEY": "your_wp_username", - "GRAVITY_FORMS_CONSUMER_SECRET": "xxxx xxxx xxxx xxxx xxxx xxxx", - "GRAVITY_FORMS_BASE_URL": "https://yoursite.com" + "GRAVITY_FORMS_CONSUMER_SECRET": "xxxx xxxx xxxx xxxx xxxx xxxx" } } } } ``` + Then restart Claude Desktop — `npx` fetches and runs `@gravitykit/mcp` on demand. Notes: + - Prefer a Gravity Forms key pair? Use `"GRAVITY_FORMS_CONSUMER_KEY": "ck_…"` and `"GRAVITY_FORMS_CONSUMER_SECRET": "cs_…"`. + - Local dev with self-signed certs: add `"GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS": "true"` to the `env` block. + - Pin a version with `@gravitykit/mcp@x.y.z` if you don't want `npx` tracking latest. ## Available Tools @@ -178,6 +151,8 @@ await mcp.call('gf_submit_form_data', { ## Configuration +Set these as environment variables — in your MCP client's `env` block (the `npx` setup above) or in a `.env` file when running from a local clone. + ### Required Environment Variables - `GRAVITY_FORMS_CONSUMER_KEY` - WordPress username (app-password setup) or GF consumer key (`ck_…`) - `GRAVITY_FORMS_CONSUMER_SECRET` - Application password or GF consumer secret (`cs_…`) @@ -323,13 +298,14 @@ npm test ### Local Development with Self-Signed Certificates -If you're using a local development environment (Laravel Valet, MAMP, Local WP, etc.) with self-signed SSL certificates, you may encounter authentication errors. To fix this: +If you're using a local development environment (Laravel Valet, MAMP, Local WP, etc.) with self-signed SSL certificates, you may encounter authentication errors. To fix this, set: -Add to your `.env` file: -```env +``` GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true ``` +in your MCP client's `env` block (`"GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS": "true"`), or in `.env` when running from a local clone. + **⚠️ Security Warning**: Only disable SSL certificate verification for local development environments. Never use this setting in production! ### Authentication Errors From 0ed6d5cc5a0a7dbedc7d8fea136be36cf8770ad3 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 21:42:30 -0400 Subject: [PATCH 33/36] =?UTF-8?q?fix:=20address=20Codex=20review=20(plane?= =?UTF-8?q?=20independence,=20dispatch,=20http-basic)=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four findings fixed test-first (RED watched, then GREEN). Logic for the three index.js issues was extracted into src/server-runtime.js so it's unit-testable (index.js self-runs main() and isn't importable). - #1 serial init: WordPress plane now starts before the Gravity Forms REST probe is awaited, so a slow/bad GF config no longer stalls the WP plane or the abilities load. (runPlaneInit) - #2 tool advertising: gf_* + field-op tools are listed only when the GF plane is live, so a WP-only install doesn't advertise tools that error on call. (buildToolList, gated on gravityFormsClient) - #3 dispatch: the call router no longer hard-codes name.startsWith('gv_'). It routes by ability-handler-map membership, so any GravityKit product prefix (gc_, …) dispatches, not just GravityView's gv_. (classifyAbilityCall) - #4 wp-client: WordPressClient refuses to send Basic auth over a remote plain-HTTP URL (credential exposure) unless GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH =true, reusing isLocalUrl — matching the GF plane's guard. Tests: test/server-runtime.test.js (10), test/wp-client.test.js (4), wired into test:node. server-runtime.js added to the AGENTS.md repo map. Verified: test:node 165, test:unit 269, prepublishOnly gate green; live MCP smoke (SDK client → node src/index.js) shows 76 tools (26 gf_ + 49 gv_ + gk_reload_abilities) and gv_layouts_list dispatches. --- AGENTS.md | 1 + package.json | 2 +- src/index.js | 92 ++++++++++++++++--------------------- src/server-runtime.js | 48 +++++++++++++++++++ src/wp-client.js | 12 +++++ test/server-runtime.test.js | 79 +++++++++++++++++++++++++++++++ test/wp-client.test.js | 32 +++++++++++++ 7 files changed, 212 insertions(+), 54 deletions(-) create mode 100644 src/server-runtime.js create mode 100644 test/server-runtime.test.js create mode 100644 test/wp-client.test.js diff --git a/AGENTS.md b/AGENTS.md index 8df6f27..e7856ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ MCP/ │ ├── gravity-forms-client.js # GravityFormsClient: GF REST HTTP client, all gf_* API methods │ ├── wp-client.js # WordPressClient: product-agnostic authenticated WP transport (Plane B) │ ├── version.js # VERSION + USER_AGENT, single-sourced from package.json +│ ├── server-runtime.js # Pure helpers: runPlaneInit, buildToolList, classifyAbilityCall │ ├── abilities/ │ │ └── loader.js # loadAbilitiesAsTools() — turns the live Abilities catalog into product tools (GravityView → gv_*) │ ├── gravityview/ # GravityView test/demo harness (NOT runtime — gv_* come from abilities/) diff --git a/package.json b/package.json index 4aa0af5..82554b9 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "verify:tool-names": "node scripts/verify-tool-names.mjs", "test": "node test/integration.test.js", "test:unit": "node test/run.js", - "test:node": "node --test test/field-manager.test.js test/field-registry.test.js test/field-dependencies.test.js test/field-positioner.test.js test/helpers.test.js test/ability-catalog.test.js test/user-agent.test.js", + "test:node": "node --test test/field-manager.test.js test/field-registry.test.js test/field-dependencies.test.js test/field-positioner.test.js test/helpers.test.js test/ability-catalog.test.js test/user-agent.test.js test/wp-client.test.js test/server-runtime.test.js", "test:auth": "node test/authentication.test.js", "test:forms": "node test/forms.test.js", "test:entries": "node test/entries.test.js", diff --git a/src/index.js b/src/index.js index 4d508f8..76cf731 100644 --- a/src/index.js +++ b/src/index.js @@ -24,6 +24,7 @@ import { sanitize } from './utils/sanitize.js'; import { stripEmpty, stripEntryMetaFromResponse } from './utils/compact.js'; import { WordPressClient } from './wp-client.js'; import { loadAbilitiesAsTools } from './abilities/loader.js'; +import { runPlaneInit, buildToolList, classifyAbilityCall } from './server-runtime.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -93,13 +94,12 @@ let gfPlaneFailedAt = 0; let wpPlaneFailedAt = 0; async function initializeClient() { - const gfOk = await initializeGravityFormsPlane(); - const wpOk = initializeWordPressPlane(); - - if (!gfOk && !wpOk) { - throw new Error('Neither Gravity Forms nor WordPress credentials are usable. Set GRAVITY_FORMS_* and/or GRAVITYKIT_WP_* in .env.'); - } - + // WP plane starts first (synchronous) so the GF REST probe can't gate it; + // runPlaneInit throws only when neither plane has usable credentials. + await runPlaneInit({ + initGravityFormsPlane: initializeGravityFormsPlane, + initWordPressPlane: initializeWordPressPlane, + }); return true; } @@ -734,35 +734,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { // next gv_* call (or gk_reload_abilities) retries. await ensureAbilitiesLoaded({ timeoutMs: 2000 }); + // Gravity Forms tools are advertised only when that plane is live, so a + // WP-only install never lists gf_* tools that can't run. gk_reload_abilities + // is always present (the manual escape hatch after fixing a WP/cert issue); + // ability tools appear once the background catalog load succeeds. + const gkReloadDef = { + name: 'gk_reload_abilities', + description: 'Force a re-fetch of the WordPress Abilities API catalog and refresh the GravityKit product tool list. Use after fixing a WP / network / cert issue that prevented the eager background load from succeeding at MCP startup.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + inputSchema: { type: 'object', properties: {}, additionalProperties: false } + }; return { - tools: [ - // Gravity Forms (Plane A) — static, always-on when GF creds work - ...GF_TOOL_DEFINITIONS, - - // Field Operations (4 tools) - Intelligent field management - ...fieldOperationTools, - - // GravityView Inspector — auto-generated from the WordPress - // Abilities API (Foundation catalog first, WP core fallback). - // Empty until the background load succeeds; gk_reload_abilities - // and the per-call self-heal repopulate it. - ...(abilityToolDefinitions ?? []), - - // Always present — the manual escape hatch when the eager - // background load fails AND the per-call self-heal hasn't fired - // (e.g. you fixed the WP env and want the tool list refreshed - // without waiting to call another gv_* tool first). - { - name: 'gk_reload_abilities', - description: 'Force a re-fetch of the WordPress Abilities API catalog and refresh the gv_* tool list. Use after fixing a WP / network / cert issue that prevented the eager background load from succeeding at MCP startup.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, - inputSchema: { - type: 'object', - properties: {}, - additionalProperties: false - } - } - ] + tools: buildToolList({ + gfReady: !!gravityFormsClient, + gfToolDefs: GF_TOOL_DEFINITIONS, + fieldOpTools: fieldOperationTools, + abilityDefs: abilityToolDefinitions, + gkReloadDef, + }) }; }); @@ -910,30 +899,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }], }; } - if (typeof name === 'string' && name.startsWith('gv_')) { - if (!wpClient) { + // Any other name is a dynamic GravityKit ability tool (any product + // prefix — gv_, gc_, …) or genuinely unknown. Self-heal the catalog + // when the WP plane is up, then route by handler-map membership so + // every product's tools dispatch, not just GravityView's. + if (wpClient) { + await ensureAbilitiesLoaded(); + } + switch (classifyAbilityCall({ name, hasWpClient: !!wpClient, handlers: abilityToolHandlers })) { + case 'dispatch': + return wrapViewHandler(() => abilityToolHandlers[name](params), params)(); + case 'no-wp-client': return createErrorResponse( 'WordPress client not initialized. Set GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD in .env (or reuse GRAVITY_FORMS_BASE_URL / GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET when the same WP install hosts both surfaces).' ); - } - // Self-heal: every gv_* call retries the abilities load if a - // prior attempt failed. Cheap (cache hit on success path), - // and lets transient cert / network / boot races recover - // without an MCP process restart. - await ensureAbilitiesLoaded(); - const handlerMap = abilityToolHandlers; - if (!handlerMap) { + case 'catalog-unreachable': return createErrorResponse( - 'GravityView abilities catalog unreachable — no gv_* tools are available. Fix WP connectivity / credentials, then call gk_reload_abilities to refresh.' + 'GravityKit abilities catalog unreachable — no product tools are available. Fix WP connectivity / credentials, then call gk_reload_abilities to refresh.' ); - } - const handler = handlerMap[name]; - if (!handler) { - return createErrorResponse(`Unknown GravityView tool: ${name}`); - } - return wrapViewHandler(() => handler(params), params)(); + default: + return createErrorResponse(`Unknown tool: ${name}`); } - return createErrorResponse(`Unknown tool: ${name}`); } }); diff --git a/src/server-runtime.js b/src/server-runtime.js new file mode 100644 index 0000000..398fe5e --- /dev/null +++ b/src/server-runtime.js @@ -0,0 +1,48 @@ +/** + * Pure helpers for the MCP server runtime, extracted from index.js so the + * two-plane behavior is unit-testable. See test/server-runtime.test.js. + */ + +/** + * Initialize the two capability planes. The WordPress plane must not be gated + * on the (potentially slow) Gravity Forms REST probe. + * @returns {Promise<{gfOk: boolean, wpOk: boolean}>} + */ +export async function runPlaneInit({ initGravityFormsPlane, initWordPressPlane }) { + // WP plane first (synchronous, instant — it fire-and-forgets the abilities + // load) so a slow GF REST probe never gates it. Then await the GF probe. + const wpOk = initWordPressPlane(); + const gfOk = await initGravityFormsPlane(); + if (!gfOk && !wpOk) { + throw new Error('Neither Gravity Forms nor WordPress credentials are usable. Set GRAVITY_FORMS_* and/or GRAVITYKIT_WP_* in .env.'); + } + return { gfOk, wpOk }; +} + +/** + * Assemble the advertised tool list. Gravity Forms tools are only listed when + * that plane is live (otherwise they'd error on call). gk_reload_abilities is + * always present; ability tools appear once the catalog loads. + */ +export function buildToolList({ gfReady, gfToolDefs = [], fieldOpTools = [], abilityDefs = [], gkReloadDef }) { + return [ + ...(gfReady ? [...gfToolDefs, ...fieldOpTools] : []), + ...(abilityDefs ?? []), + gkReloadDef, + ]; +} + +/** + * Decide how to route a call that wasn't a static Gravity Forms tool or + * gk_reload_abilities: dispatch to the dynamic ability handler map, or one of + * the error states. + * @returns {'dispatch'|'no-wp-client'|'catalog-unreachable'|'unknown'} + */ +export function classifyAbilityCall({ name, hasWpClient, handlers }) { + // Route by handler-map membership — product-agnostic, so any GravityKit + // prefix (gv_, gc_, …) dispatches as long as the catalog registered it. + if (handlers && Object.prototype.hasOwnProperty.call(handlers, name)) return 'dispatch'; + if (!hasWpClient) return 'no-wp-client'; + if (!handlers) return 'catalog-unreachable'; + return 'unknown'; +} diff --git a/src/wp-client.js b/src/wp-client.js index 198d027..adaf366 100644 --- a/src/wp-client.js +++ b/src/wp-client.js @@ -18,6 +18,7 @@ import axios from 'axios'; import https from 'https'; import { USER_AGENT } from './version.js'; +import { isLocalUrl } from './config/auth.js'; export class WordPressClient { constructor(config) { @@ -33,6 +34,17 @@ export class WordPressClient { this.baseUrl = baseUrl.replace(/\/$/, ''); + // This client always sends Basic auth (app password). Refuse to do that + // over a remote plain-HTTP URL — the credentials would travel in the + // clear. Local dev hosts (localhost, *.test, *.local) are fine; an + // explicit GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true overrides. Mirrors + // the Gravity Forms plane's guard (config/auth.js). + const allowHttpBasic = isLocalUrl(this.baseUrl) + || this.config.GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH === 'true'; + if (this.baseUrl.startsWith('http://') && !allowHttpBasic) { + throw new Error('Refusing to send Basic auth over a remote plain-HTTP URL — credentials would be exposed. Use HTTPS, or set GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true to override.'); + } + // Auth resolution order: canonical GRAVITYKIT_WP_* (prod-style) → // WORDPRESS_LOCAL_DEV_TEST_* (the local dev.test admin creds; same // values reused by any other MonoKit tool that hits the local diff --git a/test/server-runtime.test.js b/test/server-runtime.test.js new file mode 100644 index 0000000..751a015 --- /dev/null +++ b/test/server-runtime.test.js @@ -0,0 +1,79 @@ +/** + * Unit tests for server-runtime helpers (two-plane init, tool list assembly, + * ability-call routing). + */ + +import test from 'node:test'; +import assert from 'node:assert'; +import { runPlaneInit, buildToolList, classifyAbilityCall } from '../src/server-runtime.js'; + +// --- runPlaneInit (#1: WP not blocked by a slow GF probe) --- + +test('runPlaneInit: WP plane initializes before a slow GF probe resolves', async () => { + let gfResolved = false; + let gfResolvedWhenWpRan = null; + const initGravityFormsPlane = () => new Promise((r) => setTimeout(() => { gfResolved = true; r(true); }, 30)); + const initWordPressPlane = () => { gfResolvedWhenWpRan = gfResolved; return true; }; + await runPlaneInit({ initGravityFormsPlane, initWordPressPlane }); + assert.equal(gfResolvedWhenWpRan, false, 'WP plane must run before the GF probe resolves'); +}); + +test('runPlaneInit: throws when neither plane is usable', async () => { + await assert.rejects( + runPlaneInit({ initGravityFormsPlane: async () => false, initWordPressPlane: () => false }), + /Neither/ + ); +}); + +test('runPlaneInit: returns each plane status', async () => { + const r = await runPlaneInit({ initGravityFormsPlane: async () => true, initWordPressPlane: () => false }); + assert.deepEqual(r, { gfOk: true, wpOk: false }); +}); + +// --- buildToolList (#2: GF tools only when the plane is live) --- + +const defs = { + gfToolDefs: [{ name: 'gf_list_forms' }], + fieldOpTools: [{ name: 'gf_add_field' }], + abilityDefs: [{ name: 'gv_view_create' }], + gkReloadDef: { name: 'gk_reload_abilities' }, +}; + +test('buildToolList: omits GF + field-op tools when the GF plane is not ready', () => { + const names = buildToolList({ gfReady: false, ...defs }).map((t) => t.name); + assert.ok(!names.includes('gf_list_forms'), 'gf tools excluded'); + assert.ok(!names.includes('gf_add_field'), 'field-op tools excluded'); + assert.ok(names.includes('gv_view_create'), 'ability tools present'); + assert.ok(names.includes('gk_reload_abilities'), 'reload tool present'); +}); + +test('buildToolList: includes everything when the GF plane is ready', () => { + const names = buildToolList({ gfReady: true, ...defs }).map((t) => t.name).sort(); + assert.deepEqual(names, ['gf_add_field', 'gf_list_forms', 'gk_reload_abilities', 'gv_view_create']); +}); + +test('buildToolList: reload tool present, no crash on missing abilities', () => { + const names = buildToolList({ gfReady: false, gkReloadDef: defs.gkReloadDef, abilityDefs: null }).map((t) => t.name); + assert.deepEqual(names, ['gk_reload_abilities']); +}); + +// --- classifyAbilityCall (#3: route by handler-map membership, any prefix) --- + +test('classifyAbilityCall: dispatches any product-prefixed tool in the handler map', () => { + assert.equal(classifyAbilityCall({ name: 'gv_view_create', hasWpClient: true, handlers: { gv_view_create() {} } }), 'dispatch'); + assert.equal(classifyAbilityCall({ name: 'gc_chart_create', hasWpClient: true, handlers: { gc_chart_create() {} } }), 'dispatch'); +}); + +test('classifyAbilityCall: unknown when not in a loaded handler map', () => { + assert.equal(classifyAbilityCall({ name: 'gv_nope', hasWpClient: true, handlers: { gv_view_create() {} } }), 'unknown'); +}); + +test('classifyAbilityCall: no-wp-client when WP client is absent (any prefix)', () => { + assert.equal(classifyAbilityCall({ name: 'gv_view_create', hasWpClient: false, handlers: null }), 'no-wp-client'); + assert.equal(classifyAbilityCall({ name: 'gc_chart_create', hasWpClient: false, handlers: null }), 'no-wp-client'); +}); + +test('classifyAbilityCall: catalog-unreachable when WP is up but catalog not loaded (any prefix)', () => { + assert.equal(classifyAbilityCall({ name: 'gv_view_create', hasWpClient: true, handlers: null }), 'catalog-unreachable'); + assert.equal(classifyAbilityCall({ name: 'gc_chart_create', hasWpClient: true, handlers: null }), 'catalog-unreachable'); +}); diff --git a/test/wp-client.test.js b/test/wp-client.test.js new file mode 100644 index 0000000..2d287cb --- /dev/null +++ b/test/wp-client.test.js @@ -0,0 +1,32 @@ +/** + * WordPressClient refuses to send Basic auth over a remote plain-HTTP URL + * (credentials would be exposed) unless explicitly opted in — matching the + * Gravity Forms plane's guard. + */ + +import test from 'node:test'; +import assert from 'node:assert'; +import { WordPressClient } from '../src/wp-client.js'; + +const creds = { GRAVITYKIT_WP_USERNAME: 'admin', GRAVITYKIT_WP_APP_PASSWORD: 'pw' }; +const make = (extra) => () => new WordPressClient({ ...creds, ...extra }); + +test('allows HTTPS remote URLs', () => { + assert.doesNotThrow(make({ GRAVITYKIT_WP_URL: 'https://remote.example.com' })); +}); + +test('allows local plain-HTTP URLs (localhost, *.test)', () => { + assert.doesNotThrow(make({ GRAVITYKIT_WP_URL: 'http://localhost:8892' })); + assert.doesNotThrow(make({ GRAVITYKIT_WP_URL: 'http://mysite.test' })); +}); + +test('refuses Basic auth over a remote plain-HTTP URL by default', () => { + assert.throws(make({ GRAVITYKIT_WP_URL: 'http://remote.example.com' }), /http/i); +}); + +test('allows remote plain-HTTP Basic when explicitly opted in', () => { + assert.doesNotThrow(make({ + GRAVITYKIT_WP_URL: 'http://remote.example.com', + GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH: 'true', + })); +}); From c6cad886c1726d73dae38935084604db36ae991b Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 21:45:19 -0400 Subject: [PATCH 34/36] docs(readme): reflect Codex-fix behavior [ci skip] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gf_* tools are 'static, listed whenever Gravity Forms credentials are valid' (not unconditionally 'always available') — matches the now-gated tool advertising; note the two planes are independent. - GravityKit Product Tools: note they refuse remote plain-HTTP Basic auth unless GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true (HTTPS/local always fine), matching the new WordPressClient guard. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 68cbac2..47400ee 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ No clone or `npm install` needed — `npx` runs the published package on demand. ## Available Tools -Two planes: **Gravity Forms** (`gf_*`) — 26 tools, always available — and **GravityKit** — dynamic tools generated from the Foundation catalog when it's active, where each add-on registers tools under its own prefix (GravityView uses `gv_*`). The `gk_reload_abilities` tool reloads the GravityKit catalog. +Two planes: **Gravity Forms** (`gf_*`) — 26 static tools, listed whenever Gravity Forms credentials are valid — and **GravityKit** — dynamic tools generated from the Foundation catalog when it's active, where each add-on registers tools under its own prefix (GravityView uses `gv_*`). The `gk_reload_abilities` tool reloads the GravityKit catalog. The two planes are independent: a Gravity-Forms-only site lists just `gf_*`; a GravityKit site without Gravity Forms REST keys still lists its product tools. ### Forms (6 tools) - `gf_list_forms` - List forms with filtering and pagination @@ -175,7 +175,7 @@ The GravityKit product tools reach the same site over the WordPress REST Abiliti - `GRAVITYKIT_WP_USERNAME` - WordPress username (defaults to `GRAVITY_FORMS_CONSUMER_KEY`) - `GRAVITYKIT_WP_APP_PASSWORD` - Application password (defaults to `GRAVITY_FORMS_CONSUMER_SECRET`) -These tools appear only when GravityKit Foundation is active on the connected site. +These tools appear only when GravityKit Foundation is active on the connected site. They authenticate with a WordPress application password over Basic auth, so — like the Gravity Forms plane — they refuse a remote plain-HTTP URL unless `GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true` (HTTPS and local URLs are always fine). ### Authentication Flow From f2fd62ff82cd374c3702c9c30f299644728f233d Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 21:53:52 -0400 Subject: [PATCH 35/36] chore: gitignore reports/ (codex / adversarial review output) [ci skip] --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 65847b5..a700106 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,9 @@ test-results/ test-artifacts/ tmp/ +# Review reports (codex / adversarial output) +reports/ + # MCP specific mcp-server.log debug.log From 89ca3d9559c176c070240ad70916539e42eb5b3d Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 15 Jun 2026 22:25:30 -0400 Subject: [PATCH 36/36] fix: make ability checks product-agnostic; drop speculative gk_ tool names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed against the live catalog + the gravityview-multiple-forms source: gk_apply_joins / gk_list_joins are NOT real tools. Multiple Forms registers abilities in the gk-multiple-forms namespace but declares no Foundation product / mcp_prefix, so those abilities have mcp_tool_name=null and the loader skips them (live tool set is 49 gv_*, zero join tools). They were a speculative code comment + a synthetic test fixture. - src/index.js: drop the made-up "gk_apply_joins, gk_list_joins" from the list_changed comment (those tools don't exist). - ability-catalog.mjs: collectAbilityNames default prefix gk-gravityview/ -> gk- so it covers every GravityKit product namespace, not just GravityView. (test/ability-catalog.test.js: RED→GREEN for a gk-multiple-forms name.) - verify-tool-names.mjs: ABIL_RE generalized to gk-/; log/comment updated. TOOL_RE kept narrow (gf_/gv_ are the only prefixes surfacing real tools today) with a comment explaining why broadening risks false positives. Verified live: verify:tool-names 0 unknown across all surfaces; abilities now 52 (gk-gravityview 49 + gk-multiple-forms 3); test:node 166; lint:docs green. --- scripts/lib/ability-catalog.mjs | 8 +++++--- scripts/verify-tool-names.mjs | 12 +++++++++--- src/index.js | 4 ++-- test/ability-catalog.test.js | 10 ++++++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/scripts/lib/ability-catalog.mjs b/scripts/lib/ability-catalog.mjs index 6253b0f..d230831 100644 --- a/scripts/lib/ability-catalog.mjs +++ b/scripts/lib/ability-catalog.mjs @@ -4,14 +4,16 @@ /** * Collect every ability name with the given prefix from the WP Abilities - * endpoint. The endpoint paginates (default per_page 50), so all pages must - * be walked or names beyond the first page are missed. + * endpoint. Defaults to the `gk-` GravityKit prefix so it covers every + * product namespace (gk-gravityview, gk-multiple-forms, …), not just + * GravityView. The endpoint paginates (default per_page 50), so all pages + * must be walked or names beyond the first page are missed. * * @param {object} wpClient - WordPressClient (has baseUrl + httpClient.request) * @param {{prefix?: string, perPage?: number}} [opts] * @returns {Promise>} */ -export async function collectAbilityNames(wpClient, { prefix = 'gk-gravityview/', perPage = 100 } = {}) { +export async function collectAbilityNames(wpClient, { prefix = 'gk-', perPage = 100 } = {}) { const names = new Set(); for (let page = 1, totalPages = 1; page <= totalPages; page += 1) { const resp = await wpClient.httpClient.request({ diff --git a/scripts/verify-tool-names.mjs b/scripts/verify-tool-names.mjs index 669bf56..ca25a25 100644 --- a/scripts/verify-tool-names.mjs +++ b/scripts/verify-tool-names.mjs @@ -11,7 +11,8 @@ * - gf_* (static): the `name:` props in src/index.js (GF_TOOL_DEFINITIONS) * and src/field-operations/index.js (fieldOperationTools) * - gv_* (dynamic): loaded live from the connected site's catalog - * - gk-gravityview/* abilities: the live catalog (for the demo's references) + * - gk-/ abilities (any GravityKit product namespace): the live + * catalog (for the demo's ability-name references) * * Requires a live WordPress connection (same env as the server): * GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD, @@ -60,11 +61,16 @@ try { } const authToolNames = new Set([...gfStatic, ...gvDynamic]); -console.log(`Authoritative: ${gfStatic.size} gf_* + ${gvDynamic.size} gv_* = ${authToolNames.size} tools; ${abilityNames.size} gk-gravityview/* abilities\n`); +console.log(`Authoritative: ${gfStatic.size} gf_* + ${gvDynamic.size} gv_* = ${authToolNames.size} tools; ${abilityNames.size} gk-*/* abilities\n`); // --- Referenced names per surface --- +// TOOL_RE is intentionally narrow: gf_ (Gravity Forms) and gv_ (GravityView) +// are the only product prefixes that currently surface real tools. Matching +// every `g…_` token would false-positive on prose (e.g. gravityformsaddon_…), +// so extend this set deliberately when a new product registers an mcp_prefix. const TOOL_RE = /\b(g[fv]_[a-z0-9_]+)\b/g; -const ABIL_RE = /\bgk-gravityview\/[a-z0-9-]+/g; +// Any GravityKit product ability namespace (gk-gravityview/, gk-multiple-forms/, …). +const ABIL_RE = /\bgk-[a-z0-9-]+\/[a-z0-9-]+/g; // The server `instructions` string is what the agent reads — check that line // specifically rather than the whole file (which also *defines* the tools). diff --git a/src/index.js b/src/index.js index 76cf731..dfcbb2a 100644 --- a/src/index.js +++ b/src/index.js @@ -194,8 +194,8 @@ async function ensureAbilitiesLoaded({ force = false, timeoutMs } = {}) { const sourceLabel = source === 'foundation-catalog' ? 'gravitykit/v1 catalog' : '/wp-abilities/v1'; logger.info(`✅ Loaded ${count} GravityKit abilities from ${sourceLabel}`); // Tell connected MCP clients to refetch the tool list so the - // abilities-derived schemas (e.g. `joins` on view-config-apply, - // gk_apply_joins, gk_list_joins) land in their cached catalogue. + // freshly loaded ability tools and their schemas land in the + // client's cached catalogue. server.sendToolListChanged().catch((err) => { logger.warn(`tools/list_changed notification failed: ${err.message}`); }); diff --git a/test/ability-catalog.test.js b/test/ability-catalog.test.js index cec6128..dbcd9c5 100644 --- a/test/ability-catalog.test.js +++ b/test/ability-catalog.test.js @@ -40,3 +40,13 @@ test('collectAbilityNames: single page when only one page exists', async () => { const names = await collectAbilityNames(client); assert.deepEqual([...names], ['gk-gravityview/layouts-list']); }); + +test('collectAbilityNames: collects every GravityKit product namespace, not just gravityview', async () => { + const client = mockClient([[ + { name: 'gk-gravityview/layouts-list' }, + { name: 'gk-multiple-forms/list-joins' }, + { name: 'core/get-site-info' }, + ]]); + const names = await collectAbilityNames(client); + assert.deepEqual([...names].sort(), ['gk-gravityview/layouts-list', 'gk-multiple-forms/list-joins']); +});