Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ jobs:
run: npm publish --access public

- name: Create GitHub Release
id: release
if: steps.guard.outputs.published == 'false'
env:
GH_TOKEN: ${{ github.token }}
Expand All @@ -96,3 +97,48 @@ jobs:
--notes "See [CHANGELOG.md](https://github.com/$GITHUB_REPOSITORY/blob/main/CHANGELOG.md) for details.

Install: \`npm install @gravitykit/mcp@$VERSION\` · [View on npm](https://www.npmjs.com/package/@gravitykit/mcp/v/$VERSION)"
# Signals the Slack step that a release was actually created this run
# (not an idempotent skip), so announcements never double-fire.
echo "created=true" >> "$GITHUB_OUTPUT"

- name: Announce release to Slack
# Mirrors gktools' "Notify Slack": POST {link,version,changelog,product}
# to SLACK_RELEASE_NOTIFICATION_WEBHOOK so GravityKit MCP shows up in
# #release-announcements like every other plugin. Only after a release is
# actually created; never fails the job.
if: steps.release.outputs.created == 'true'
env:
SLACK_RELEASE_NOTIFICATION_WEBHOOK: ${{ secrets.SLACK_RELEASE_NOTIFICATION_WEBHOOK }}
# Bind expressions through env (quoted shell vars below) instead of
# interpolating ${{ }} into the run script — avoids template-to-shell injection.
VERSION: ${{ steps.guard.outputs.version }}
REPO: ${{ github.repository }}
run: |
if [ -z "${SLACK_RELEASE_NOTIFICATION_WEBHOOK:-}" ]; then
echo "SLACK_RELEASE_NOTIFICATION_WEBHOOK not set — skipping Slack announcement."
exit 0
fi
LINK="https://github.com/$REPO/releases/tag/v$VERSION"
# Extract this version's CHANGELOG section: lines between "## [VERSION]"
# and the next "## [" header.
NOTES="$(awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { capture=1; next }
capture && /^## \[/ { exit }
capture { print }
' CHANGELOG.md)"
# jq builds the JSON so newlines/quotes in the changelog are escaped.
PAYLOAD="$(jq -n \
--arg link "$LINK" \
--arg version "$VERSION" \
--arg changelog "$NOTES" \
--arg product "GravityKit MCP" \
'{link:$link,version:$version,changelog:$changelog,product:$product}')"
# Non-fatal, like gktools: a bad Slack response only warns.
RESPONSE="$(curl -sS -X POST \
-H 'Content-Type: application/json' \
--data "$PAYLOAD" \
"$SLACK_RELEASE_NOTIFICATION_WEBHOOK" || true)"
echo "Slack response: $RESPONSE"
if ! printf '%s' "$RESPONSE" | grep -q '"ok":true'; then
echo "::warning::Slack release announcement did not return \"ok\":true."
fi
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This is the single canonical doc for the project (agents and humans). `CLAUDE.md

## Project Identity

- **Package:** `@gravitykit/mcp` v2.4.0
- **Package:** `@gravitykit/mcp` v2.4.1
- **Type:** Node.js MCP server (ESM)
- **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
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to GravityKit MCP (formerly GravityMCP) will be documented i
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.4.1] - 2026-06-25

This release lets the field tools work with custom and third-party field types (not just the ones built into Gravity Forms), and tightens input handling and security across the server.

### 🐛 Fixed
- **`gf_add_field` now accepts custom and third-party field types.** Field types added by other plugins were previously rejected because they aren't in the built-in registry. They are now created normally, with a note when type-specific defaults or sub-inputs aren't available; pass `inputs`/`choices` explicitly for custom compound or choice fields.
- **Compound field sub-inputs stay consistent with the field's id.** When a field's id is auto-assigned or differs from the one supplied, its sub-input ids (e.g. `5.1`, `5.2`) are rebased to match, so `name`, `address`, and custom compound fields save to the right inputs.
- **`gf_update_field` no longer saves a change it then reports as blocked.** When a field had dependent conditional logic and `force` wasn't set, the update was written before the "use force to proceed" response, so the check protected nothing. The dependency check now runs before the write (matching `gf_delete_field`).
- **Hardened tool-input handling.** Malformed or hostile input to the field, validation, and dependency helpers (empty/`null`/non-string field types, `null` properties or position, a `null` entry in a form's `fields`, non-scalar filter values, self-referential objects) now returns a clean error or is handled safely instead of failing opaquely.
- **Security and validation hardening.** Loopback detection no longer treats a remote `127.x` domain as local (which could have allowed Basic auth over plain HTTP); `Cookie` headers and values are masked in logs; confirmation-redirect URL validation accepts dotless hosts (localhost/intranet); and duplicate or out-of-range explicit field ids are corrected so generated forms stay valid (an out-of-range id can no longer stall form creation).

## [2.4.0] - 2026-06-19

This update adds additional guards to make sure Gravity Forms entries save correctly for every field type, so an AI assistant gets the entry format right the first time. Adds explicit support for the `password` field type and Gravity Wiz Nested Forms, plus a benchmark suite that ensures the MCP works well with small models.
Expand Down Expand Up @@ -236,6 +247,7 @@ A correctness pass on the Gravity Forms (`gf_*`) plane, verified against Gravity
- Field filters (1 tool)
- Results/Analytics (1 tool)

[2.4.1]: https://github.com/GravityKit/MCP/releases/tag/v2.4.1
[2.4.0]: https://github.com/GravityKit/MCP/releases/tag/v2.4.0
[2.3.0]: https://github.com/GravityKit/MCP/releases/tag/v2.3.0
[2.2.0]: https://github.com/GravityKit/MCP/releases/tag/v2.2.0
Expand Down
2 changes: 1 addition & 1 deletion mcp.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gravitykit-mcp",
"version": "2.4.0",
"version": "2.4.1",
"description": "MCP server for Gravity Forms",
"author": "GravityKit",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gravitykit/mcp",
"version": "2.4.0",
"version": "2.4.1",
"description": "Full-featured MCP server for Gravity Forms",
"main": "src/index.js",
"type": "module",
Expand Down
4 changes: 2 additions & 2 deletions src/abilities/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ const GK_NAME_PATTERN = /^gk-[a-z0-9-]+\//;
* @returns {'GET'|'POST'|'DELETE'}
*/
export function methodForAbility(annotations = {}) {
if (annotations.readonly) return 'GET';
if (annotations?.readonly) return 'GET';
// 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';
if (annotations?.destructive && annotations?.idempotent) return 'DELETE';
return 'POST';
}

Expand Down
6 changes: 5 additions & 1 deletion src/config/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ import logger from '../utils/logger.js';
export function isLocalUrl(baseUrl) {
try {
const { hostname } = new URL(baseUrl);
// 127.0.0.0/8 loopback must be a genuine IPv4: a remote domain like
// "127.example.com" also `startsWith('127.')` and must NOT count as local,
// or Basic auth would be allowed in the clear over plain HTTP to a remote host.
const isLoopbackIp = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
return hostname === 'localhost'
|| hostname === '[::1]'
|| hostname === '::1'
|| hostname.endsWith('.localhost')
|| hostname.startsWith('127.')
|| isLoopbackIp
|| hostname.endsWith('.test')
|| hostname.endsWith('.local');
} catch {
Expand Down
10 changes: 6 additions & 4 deletions src/config/field-validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export class FieldAwareValidator {
} else {
errors.push({
index: i,
fieldId: field.id,
fieldType: field.type,
fieldId: field?.id,
fieldType: field?.type,
error: validation.error
});
}
Expand Down Expand Up @@ -89,10 +89,12 @@ export class FieldAwareValidator {
logger.warn(`[FieldValidator] Unknown field type '${field.type}' at ${path}`);
}

// Allow unknown types but mark them
// Allow unknown types (third-party add-ons, GravityKit, custom fields).
// Gravity Forms accepts them on save; pass the field through unchanged. No
// internal flag is added: it would be PUT verbatim and nothing reads it.
return {
isValid: true,
field: { ...field, _unknown: true }
field: { ...field }
};
}

Expand Down
14 changes: 8 additions & 6 deletions src/config/validation-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const VALIDATION_CONFIG = {
maxLength: 254 // RFC 5321
},
url: {
pattern: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/,
pattern: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6})?\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/,
maxLength: 2083 // IE limit
},
slug: {
Expand Down Expand Up @@ -104,13 +104,15 @@ export const VALIDATION_CONFIG = {
* Get error message with field name and parameters
*/
export function formatErrorMessage(template, field, params = {}) {
let message = template.replace('{field}', field);

// split/join replaces EVERY placeholder and treats the value literally.
// String.replace(str, str) only swaps the first match and interprets `$&`/`$1`
// sequences in the replacement string.
let message = template.split('{field}').join(field);

Object.keys(params).forEach(key => {
const placeholder = `{${key}}`;
message = message.replace(placeholder, params[key]);
message = message.split(`{${key}}`).join(String(params[key]));
});

return message;
}

Expand Down
7 changes: 7 additions & 0 deletions src/config/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ export class BaseValidator {
throw new Error('Field filter must have a value');
}

// Reject non-array objects: String({}) becomes "[object Object]", a silent
// garbage filter value. Arrays stay valid for the multi-value operators
// handled below; a plain object never is.
if (typeof value === 'object' && !Array.isArray(value)) {
throw new Error('Field filter value must be a string, number, boolean, or array');
}

// GF normalizes operators case-insensitively (IN/in/In all match), so accept
// any case rather than rejecting valid GF operators.
const validOperators = getEnumValues('fieldOperators');
Expand Down
79 changes: 51 additions & 28 deletions src/field-definitions/field-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,9 @@ export function getStorageFormat(fieldType) {
* Helper function to detect field variant
*/
export function detectFieldVariant(field) {
if (!field || typeof field !== 'object') {
return 'default';
}
const definition = fieldRegistry[field.type];
if (!definition || !definition.variants) {
return 'default';
Expand All @@ -927,13 +930,16 @@ export function detectFieldVariant(field) {
* Validate field configuration
*/
export function validateFieldConfig(field) {
if (!field || typeof field !== 'object') {
return { isValid: false, error: 'Field must be an object' };
}
const definition = fieldRegistry[field.type];


// Unknown / third-party types are tolerated, not rejected (Gravity Forms
// accepts them on save). With no config schema to check them against there is
// nothing to validate, so report valid and let GF own it.
if (!definition) {
return {
isValid: false,
error: `Unknown field type: ${field.type}`
};
return { isValid: true };
}

// Check required properties
Expand Down Expand Up @@ -978,42 +984,56 @@ export function assignFieldIds(fields) {
return fields;
}

const used = new Set();
// Rebase any dotted compound sub-input ids onto `parentId` so they always
// track the field's FINAL id — applied whether the id is preserved or newly
// generated, so a caller mismatch (id 5 with sub-input "9.1") never orphans
// sub-inputs. Non-array inputs / non-dotted ids pass through unchanged.
const rebaseInputs = (field, parentId) => {
if (!Array.isArray(field?.inputs)) {
return field;
}
const inputs = field.inputs.map((input) => {
const hasDottedId = input && typeof input.id === 'string' && input.id.includes('.');
if (!hasDottedId) {
return input;
}
const sub = input.id.slice(input.id.indexOf('.') + 1);
return { ...input, id: `${parentId}.${sub}` };
});
return { ...field, inputs };
};

// Only SAFE positive integers count as explicit ids. Number.isInteger is true
// for values like 1e308 where `next++` can never advance (1e308 + 1 === 1e308),
// which would hang the reassignment loop — treat those as unset and give them a
// small sequential id instead.
let next = 1;
for (const field of fields) {
const id = Number(field?.id);
if (Number.isInteger(id) && id > 0) {
used.add(id);
if (Number.isSafeInteger(id) && id > 0 && id >= next) {
next = id + 1;
}
}

let next = (used.size ? Math.max(...used) : 0) + 1;

// Keep the FIRST occurrence of each explicit id; reassign fields with no id OR
// a duplicate explicit id, so the result never contains colliding field ids.
const claimed = new Set();
return fields.map((field) => {
const id = Number(field?.id);
if (Number.isInteger(id) && id > 0) {
return field;
const hasFreshExplicitId = Number.isSafeInteger(id) && id > 0 && !claimed.has(id);
if (hasFreshExplicitId) {
claimed.add(id);
// Id preserved, but sub-inputs may still reference a stale parent — rebase.
return rebaseInputs(field, id);
}

while (used.has(next)) {
while (claimed.has(next)) {
next++;
}
const newId = next++;
used.add(newId);

// Re-base any provided compound sub-input ids onto the new field id.
if (Array.isArray(field?.inputs)) {
const inputs = field.inputs.map((input) => {
const hasDottedId = input && typeof input.id === 'string' && input.id.includes('.');
if (hasDottedId) {
const sub = input.id.slice(input.id.indexOf('.') + 1);
return { ...input, id: `${newId}.${sub}` };
}
return input;
});
return { ...field, id: newId, inputs };
}
claimed.add(newId);

return { ...field, id: newId };
return rebaseInputs({ ...field, id: newId }, newId);
});
}

Expand Down Expand Up @@ -1059,6 +1079,9 @@ export function getCompoundFieldInputs(fieldType) {
* @returns {array|null} Array of input definitions or null if not a compound field.
*/
export function generateCompoundInputs(field) {
if (!field || typeof field !== 'object') {
return null;
}
const fieldDef = fieldRegistry[field.type];

if (!fieldDef || !fieldDef.isCompound) {
Expand Down
13 changes: 10 additions & 3 deletions src/field-operations/field-dependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export class DependencyTracker {
dynamicPopulation: []
};

if (!form || typeof form !== 'object') {
return dependencies;
}

// 1. Check conditional logic dependencies in all fields
this.scanConditionalLogic(form, fieldId, dependencies);

Expand Down Expand Up @@ -260,10 +264,13 @@ export class DependencyTracker {
* Check if dependencies would break form functionality
*/
hasBreakingDependencies(dependencies) {
if (!dependencies || typeof dependencies !== 'object') {
return false;
}
return (
dependencies.conditionalLogic.length > 0 ||
dependencies.calculations.length > 0 ||
dependencies.mergeTags.length > 0
(dependencies.conditionalLogic?.length > 0) ||
(dependencies.calculations?.length > 0) ||
(dependencies.mergeTags?.length > 0)
);
}

Expand Down
Loading
Loading