Skip to content

Commit 988201d

Browse files
committed
feat: strip plugin entry meta from responses by default
Entry tools now strip plugin-added meta keys (gv_revision_*, helpscout_conversation_id, conversion_bridge_session, etc.) by default, keeping only core GF properties and numbered field values. Pass compact=false for full raw data including all entry meta. - Add stripEntryMeta() and stripEntryMetaFromResponse() to compact.js - Apply to gf_list_entries, gf_get_entry, gf_create_entry, gf_update_entry - Add 6 new tests (23 compact tests total) - Bump to v1.2.0
1 parent 295165c commit 988201d

5 files changed

Lines changed: 191 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Responses are optimized for minimal token usage:
123123
- **Compact JSON**: `JSON.stringify(result)` — no pretty-printing (no `null, 2`) — `src/index.js:114`
124124
- **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 }`)
125125
- **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.
126-
- **Compact mode (default on)**: `stripEmpty()` (`utils/compact.js`) recursively removes `null` and `""` values from all responses via `wrapHandler()`. `false` is preserved (semantic meaning, e.g. `is_active: false`). `"0"`/`"1"` strings are preserved (GF boolean pattern). Data-heavy GET tools expose a `compact` parameter — pass `compact=false` for raw unstripped data.
126+
- **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.
127127
- **Concise tool descriptions**: All 28 tool descriptions and property descriptions are terse to reduce tool-list overhead
128128

129129
### Tool Categories

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ Required env vars (see `.env.example` for full list):
3535
5. **Update operations fetch-then-merge** — always GET existing data first to avoid data loss
3636
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.
3737
7. **Keep tool descriptions terse** — every token in tool schemas is sent on every `tools/list` call
38-
8. **Compact mode strips null and empty strings**`stripEmpty()` in `utils/compact.js` runs on all responses by default. `false` is preserved (semantic meaning). Agents pass `compact=false` for raw data.
38+
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.

src/index.js

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import fieldRegistry from './field-definitions/field-registry.js';
2121
import FieldAwareValidator from './config/field-validation.js';
2222
import logger from './utils/logger.js';
2323
import { sanitize } from './utils/sanitize.js';
24-
import { stripEmpty } from './utils/compact.js';
24+
import { stripEmpty, stripEntryMetaFromResponse } from './utils/compact.js';
2525

2626
const __filename = fileURLToPath(import.meta.url);
2727
const __dirname = dirname(__filename);
@@ -235,7 +235,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
235235
// Entries Management (6 tools)
236236
{
237237
name: 'gf_list_entries',
238-
description: 'List/search entries. Omits null/empty values by default; pass compact=false for raw data.',
238+
description: 'List/search entries. Strips null/empty values and plugin entry meta by default; pass compact=false for full raw data.',
239239
inputSchema: {
240240
type: 'object',
241241
properties: {
@@ -306,7 +306,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
306306
},
307307
{
308308
name: 'gf_get_entry',
309-
description: 'Get an entry by ID. Omits null/empty values by default; pass compact=false for raw data.',
309+
description: 'Get an entry by ID. Strips null/empty values and plugin entry meta by default; pass compact=false for full raw data.',
310310
inputSchema: {
311311
type: 'object',
312312
properties: {
@@ -563,13 +563,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
563563

564564
// Entries Management
565565
case 'gf_list_entries':
566-
return wrapHandler(() => gravityFormsClient.listEntries(params), params)();
566+
return wrapHandler(async () => {
567+
const result = await gravityFormsClient.listEntries(params);
568+
return params.compact !== false ? stripEntryMetaFromResponse(result) : result;
569+
}, params)();
567570
case 'gf_get_entry':
568-
return wrapHandler(() => gravityFormsClient.getEntry(params), params)();
571+
return wrapHandler(async () => {
572+
const result = await gravityFormsClient.getEntry(params);
573+
return params.compact !== false ? stripEntryMetaFromResponse(result) : result;
574+
}, params)();
569575
case 'gf_create_entry':
570-
return wrapHandler(() => gravityFormsClient.createEntry(params), params)();
576+
return wrapHandler(async () => {
577+
const result = await gravityFormsClient.createEntry(params);
578+
return params.compact !== false ? stripEntryMetaFromResponse(result) : result;
579+
}, params)();
571580
case 'gf_update_entry':
572-
return wrapHandler(() => gravityFormsClient.updateEntry(params), params)();
581+
return wrapHandler(async () => {
582+
const result = await gravityFormsClient.updateEntry(params);
583+
return params.compact !== false ? stripEntryMetaFromResponse(result) : result;
584+
}, params)();
573585
case 'gf_delete_entry':
574586
return wrapHandler(() => gravityFormsClient.deleteEntry(params), params)();
575587

src/tests/compact.test.js

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* Preserves: false, 0, "0"
88
*/
99

10-
import { stripEmpty } from '../utils/compact.js';
10+
import { stripEmpty, stripEntryMeta, stripEntryMetaFromResponse } from '../utils/compact.js';
1111
import { TestRunner, TestAssert } from './helpers.js';
1212

1313
const runner = new TestRunner('Compact Utility Tests');
@@ -220,6 +220,123 @@ runner.test('stripEmpty: does not mutate original object', () => {
220220
TestAssert.equal(Object.keys(input).length, 3, 'original key count unchanged');
221221
});
222222

223+
// --- stripEntryMeta ---
224+
225+
runner.test('stripEntryMeta: keeps core properties and field values', () => {
226+
const entry = {
227+
id: '123',
228+
form_id: '29',
229+
status: 'active',
230+
date_created: '2024-01-15',
231+
date_updated: '2024-01-15',
232+
is_starred: '0',
233+
is_read: '0',
234+
ip: '127.0.0.1',
235+
source_url: 'https://example.com',
236+
user_agent: 'node',
237+
currency: 'USD',
238+
created_by: '1',
239+
'2': 'test@example.com',
240+
'3': 'some survey value',
241+
'6': '21621',
242+
gv_revision_parent_id: false,
243+
gv_revision_date: false,
244+
gv_revision_date_gmt: false,
245+
gv_revision_user_id: false,
246+
gv_revision_changed: false,
247+
helpscout_conversation_id: false,
248+
conversion_bridge_session: false
249+
};
250+
const result = stripEntryMeta(entry);
251+
252+
// Core properties kept
253+
TestAssert.equal(result.id, '123', 'id kept');
254+
TestAssert.equal(result.form_id, '29', 'form_id kept');
255+
TestAssert.equal(result.status, 'active', 'status kept');
256+
TestAssert.equal(result.created_by, '1', 'created_by kept');
257+
TestAssert.equal(result.currency, 'USD', 'currency kept');
258+
259+
// Field values kept
260+
TestAssert.equal(result['2'], 'test@example.com', 'field 2 kept');
261+
TestAssert.equal(result['3'], 'some survey value', 'field 3 kept');
262+
TestAssert.equal(result['6'], '21621', 'field 6 kept');
263+
264+
// Plugin meta stripped
265+
TestAssert.equal(result.gv_revision_parent_id, undefined, 'gv_revision_parent_id stripped');
266+
TestAssert.equal(result.gv_revision_date, undefined, 'gv_revision_date stripped');
267+
TestAssert.equal(result.helpscout_conversation_id, undefined, 'helpscout_conversation_id stripped');
268+
TestAssert.equal(result.conversion_bridge_session, undefined, 'conversion_bridge_session stripped');
269+
});
270+
271+
runner.test('stripEntryMeta: keeps compound field keys (dot notation)', () => {
272+
const entry = {
273+
id: '456',
274+
form_id: '5',
275+
status: 'active',
276+
'5.1': '123 Main St',
277+
'5.2': 'Apt 4',
278+
'5.3': 'Springfield',
279+
'5.4': 'IL',
280+
'5.5': '62701',
281+
some_plugin_meta: 'value'
282+
};
283+
const result = stripEntryMeta(entry);
284+
TestAssert.equal(result['5.1'], '123 Main St', 'compound field 5.1 kept');
285+
TestAssert.equal(result['5.3'], 'Springfield', 'compound field 5.3 kept');
286+
TestAssert.equal(result.some_plugin_meta, undefined, 'plugin meta stripped');
287+
});
288+
289+
runner.test('stripEntryMeta: keeps payment fields when present', () => {
290+
const entry = {
291+
id: '789',
292+
form_id: '10',
293+
status: 'active',
294+
payment_status: 'Paid',
295+
payment_amount: '49.99',
296+
payment_method: 'stripe',
297+
transaction_id: 'txn_123',
298+
custom_addon_field: 'noise'
299+
};
300+
const result = stripEntryMeta(entry);
301+
TestAssert.equal(result.payment_status, 'Paid', 'payment_status kept');
302+
TestAssert.equal(result.payment_amount, '49.99', 'payment_amount kept');
303+
TestAssert.equal(result.transaction_id, 'txn_123', 'transaction_id kept');
304+
TestAssert.equal(result.custom_addon_field, undefined, 'custom addon stripped');
305+
});
306+
307+
// --- stripEntryMetaFromResponse ---
308+
309+
runner.test('stripEntryMetaFromResponse: handles entries array', () => {
310+
const response = {
311+
entries: [
312+
{ id: '1', form_id: '5', status: 'active', '2': 'val', plugin_meta: 'x' },
313+
{ id: '2', form_id: '5', status: 'active', '3': 'val', plugin_meta: 'y' }
314+
],
315+
total_count: 2
316+
};
317+
const result = stripEntryMetaFromResponse(response);
318+
TestAssert.equal(result.entries.length, 2, 'array length preserved');
319+
TestAssert.equal(result.total_count, 2, 'total_count preserved');
320+
TestAssert.equal(result.entries[0].plugin_meta, undefined, 'meta stripped from first');
321+
TestAssert.equal(result.entries[1].plugin_meta, undefined, 'meta stripped from second');
322+
TestAssert.equal(result.entries[0]['2'], 'val', 'field value kept in first');
323+
});
324+
325+
runner.test('stripEntryMetaFromResponse: handles single entry', () => {
326+
const response = {
327+
entry: { id: '1', form_id: '5', status: 'active', '2': 'val', plugin_meta: 'x' }
328+
};
329+
const result = stripEntryMetaFromResponse(response);
330+
TestAssert.equal(result.entry.plugin_meta, undefined, 'meta stripped');
331+
TestAssert.equal(result.entry['2'], 'val', 'field value kept');
332+
});
333+
334+
runner.test('stripEntryMetaFromResponse: passes through non-entry responses', () => {
335+
const response = { forms: [{ id: 1 }] };
336+
const result = stripEntryMetaFromResponse(response);
337+
TestAssert.equal(result.forms[0].id, 1, 'non-entry response unchanged');
338+
});
339+
223340
// Run tests
224341
runner.run().then(results => {
225342
process.exit(results.failed > 0 ? 1 : 0);

src/utils/compact.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,55 @@ export function stripEmpty(obj) {
2727
return obj;
2828
}
2929

30-
export default { stripEmpty };
30+
/**
31+
* Core entry properties returned by the GF REST API.
32+
* Everything else is plugin-added entry meta (stripped by default).
33+
*/
34+
const CORE_ENTRY_KEYS = new Set([
35+
'id', 'form_id', 'post_id', 'date_created', 'date_updated',
36+
'is_starred', 'is_read', 'ip', 'source_url', 'user_agent',
37+
'currency', 'payment_status', 'payment_date', 'payment_amount',
38+
'payment_method', 'transaction_id', 'is_fulfilled', 'created_by',
39+
'transaction_type', 'status', 'source_id'
40+
]);
41+
42+
/**
43+
* Test if a key is a field value (numeric or dot-notation like "5.1").
44+
*/
45+
function isFieldKey(key) {
46+
return /^\d+(\.\d+)?$/.test(key);
47+
}
48+
49+
/**
50+
* Strip plugin-added entry meta from an entry object.
51+
* Keeps core properties and numbered field values.
52+
* @param {object} entry - Single entry object
53+
* @returns {object} Entry with only core + field keys
54+
*/
55+
export function stripEntryMeta(entry) {
56+
const result = {};
57+
for (const [key, value] of Object.entries(entry)) {
58+
if (CORE_ENTRY_KEYS.has(key) || isFieldKey(key)) {
59+
result[key] = value;
60+
}
61+
}
62+
return result;
63+
}
64+
65+
/**
66+
* Strip entry meta from a response containing entries.
67+
* Handles both { entries: [...] } and { entry: {...} } shapes.
68+
* @param {object} response - Tool response object
69+
* @returns {object} Response with entry meta stripped
70+
*/
71+
export function stripEntryMetaFromResponse(response) {
72+
if (response.entries && Array.isArray(response.entries)) {
73+
return { ...response, entries: response.entries.map(stripEntryMeta) };
74+
}
75+
if (response.entry && typeof response.entry === 'object') {
76+
return { ...response, entry: stripEntryMeta(response.entry) };
77+
}
78+
return response;
79+
}
80+
81+
export default { stripEmpty, stripEntryMeta, stripEntryMetaFromResponse };

0 commit comments

Comments
 (0)