@gxp-dev/tools 2.0.71 → 2.0.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,549 @@
1
+ /**
2
+ * MCP tools for reading and mutating GxP configuration.json /
3
+ * app-manifest.json files. All mutations are linted before they touch disk:
4
+ * if the edit would produce an invalid document, the tool refuses to save and
5
+ * returns the errors, unless the caller passes `force: true`.
6
+ *
7
+ * Paths referenced from outside the project root are accepted as absolute; any
8
+ * other path is resolved relative to process.cwd() (which is typically the
9
+ * consumer project when the MCP server is spawned by an AI tool).
10
+ */
11
+
12
+ const fs = require("fs")
13
+ const path = require("path")
14
+
15
+ const {
16
+ getByPointer,
17
+ setByPointer,
18
+ deleteByPointer,
19
+ insertAt,
20
+ moveItem,
21
+ listCards,
22
+ listFields,
23
+ } = require("./config-ops")
24
+
25
+ // Reuse the Phase-1 linter; published as a sibling of the MCP dir.
26
+ const lintDir = path.resolve(__dirname, "../../bin/lib/lint")
27
+ const { lintFile, lintData, detectSchema } = require(lintDir)
28
+
29
+ // Reuse the existing extract-config utility (same logic as `gxdev extract-config`).
30
+ const extractUtil = require(
31
+ path.resolve(__dirname, "../../bin/lib/utils/extract-config"),
32
+ )
33
+
34
+ const SCHEMA_DIR = path.join(lintDir, "schemas")
35
+
36
+ /* ----------------------------- shared helpers ---------------------------- */
37
+
38
+ function resolveProjectPath(p) {
39
+ if (!p) {
40
+ throw new Error("`path` argument is required")
41
+ }
42
+ return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p)
43
+ }
44
+
45
+ function readJson(absPath) {
46
+ const src = fs.readFileSync(absPath, "utf-8")
47
+ try {
48
+ return JSON.parse(src)
49
+ } catch (e) {
50
+ throw new Error(`Invalid JSON in ${absPath}: ${e.message}`)
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Write `doc` to `absPath`, but only after linting the prospective contents
56
+ * in-memory. Returns { ok, errors, wrote }. Disk is untouched on failure
57
+ * unless `force: true`.
58
+ */
59
+ function writeLinted(absPath, doc, { force = false } = {}) {
60
+ const result = lintData(doc, absPath)
61
+ if (!result.ok && !force) {
62
+ return { ok: false, errors: result.errors, wrote: false }
63
+ }
64
+ fs.writeFileSync(absPath, JSON.stringify(doc, null, "\t"), "utf-8")
65
+ return {
66
+ ok: result.ok,
67
+ errors: result.errors,
68
+ wrote: true,
69
+ forced: !result.ok && force,
70
+ }
71
+ }
72
+
73
+ function readSchemaFile(name) {
74
+ return JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, name), "utf-8"))
75
+ }
76
+
77
+ function contentResult(obj) {
78
+ return {
79
+ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
80
+ }
81
+ }
82
+
83
+ /* ------------------------------ tool schemas ----------------------------- */
84
+
85
+ const CONFIG_TOOLS = [
86
+ {
87
+ name: "config_validate",
88
+ description:
89
+ "Validate a GxP configuration.json or app-manifest.json against the templating schema. Returns {ok, errors[]}.",
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: {
93
+ path: {
94
+ type: "string",
95
+ description: "Absolute or project-relative path to the JSON file.",
96
+ },
97
+ },
98
+ required: ["path"],
99
+ },
100
+ },
101
+ {
102
+ name: "config_list_field_types",
103
+ description:
104
+ "List every field type the linter accepts (e.g. 'text', 'select', 'asyncSelect').",
105
+ inputSchema: { type: "object", properties: {}, required: [] },
106
+ },
107
+ {
108
+ name: "config_list_card_types",
109
+ description:
110
+ "List every card type the linter accepts (e.g. 'fields_list', 'card_list', 'tabs_list').",
111
+ inputSchema: { type: "object", properties: {}, required: [] },
112
+ },
113
+ {
114
+ name: "config_get_field_schema",
115
+ description:
116
+ "Return the JSON schema for a specific field type, including its required properties and common options. Use this before writing a new field to see what shape it needs.",
117
+ inputSchema: {
118
+ type: "object",
119
+ properties: {
120
+ type: {
121
+ type: "string",
122
+ description: "Field type, e.g. 'text' or 'select'.",
123
+ },
124
+ },
125
+ required: ["type"],
126
+ },
127
+ },
128
+ {
129
+ name: "config_list_cards",
130
+ description:
131
+ "List every card in a configuration.json with its JSON pointer, type, title, and field count. Use the returned `path` as the target for add/move/remove calls.",
132
+ inputSchema: {
133
+ type: "object",
134
+ properties: {
135
+ path: { type: "string", description: "Path to configuration.json" },
136
+ },
137
+ required: ["path"],
138
+ },
139
+ },
140
+ {
141
+ name: "config_list_fields",
142
+ description:
143
+ "List every field inside a specific fields_list card, with JSON pointers.",
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: {
147
+ path: { type: "string", description: "Path to configuration.json" },
148
+ card_path: {
149
+ type: "string",
150
+ description:
151
+ "JSON pointer to the fields_list card (from config_list_cards).",
152
+ },
153
+ },
154
+ required: ["path", "card_path"],
155
+ },
156
+ },
157
+ {
158
+ name: "config_add_field",
159
+ description:
160
+ "Add a field to a fields_list card. Validates the resulting document; rejects the save if it would be invalid (unless force=true).",
161
+ inputSchema: {
162
+ type: "object",
163
+ properties: {
164
+ path: { type: "string" },
165
+ card_path: {
166
+ type: "string",
167
+ description: "JSON pointer to the fields_list card.",
168
+ },
169
+ field: {
170
+ type: "object",
171
+ description:
172
+ "Field definition to insert. Must include `type` and (for most types) `name`.",
173
+ },
174
+ position: {
175
+ type: ["integer", "string"],
176
+ description: "Index to insert at, or 'end' (default).",
177
+ },
178
+ force: { type: "boolean", default: false },
179
+ },
180
+ required: ["path", "card_path", "field"],
181
+ },
182
+ },
183
+ {
184
+ name: "config_move_field",
185
+ description:
186
+ "Move a field from one position in a fields_list to another (same card or different card).",
187
+ inputSchema: {
188
+ type: "object",
189
+ properties: {
190
+ path: { type: "string" },
191
+ from_path: {
192
+ type: "string",
193
+ description: "JSON pointer to the field.",
194
+ },
195
+ to_card_path: {
196
+ type: "string",
197
+ description: "JSON pointer to the destination fields_list card.",
198
+ },
199
+ position: {
200
+ type: ["integer", "string"],
201
+ description: "Index in the destination, or 'end' (default).",
202
+ },
203
+ force: { type: "boolean", default: false },
204
+ },
205
+ required: ["path", "from_path", "to_card_path"],
206
+ },
207
+ },
208
+ {
209
+ name: "config_remove_field",
210
+ description: "Remove a field by JSON pointer.",
211
+ inputSchema: {
212
+ type: "object",
213
+ properties: {
214
+ path: { type: "string" },
215
+ field_path: {
216
+ type: "string",
217
+ description: "JSON pointer to the field to remove.",
218
+ },
219
+ force: { type: "boolean", default: false },
220
+ },
221
+ required: ["path", "field_path"],
222
+ },
223
+ },
224
+ {
225
+ name: "config_add_card",
226
+ description:
227
+ "Add a card under a parent container (additionalTabs, a card_list's cards[], or a tabs_list tab).",
228
+ inputSchema: {
229
+ type: "object",
230
+ properties: {
231
+ path: { type: "string" },
232
+ parent_path: {
233
+ type: "string",
234
+ description:
235
+ "JSON pointer to the parent array of cards (e.g. '/additionalTabs' or '/additionalTabs/0/cards').",
236
+ },
237
+ card: {
238
+ type: "object",
239
+ description:
240
+ "Card definition. Must include `type`. For fields_list, include `fieldsList: []`.",
241
+ },
242
+ position: {
243
+ type: ["integer", "string"],
244
+ description: "Index or 'end' (default).",
245
+ },
246
+ force: { type: "boolean", default: false },
247
+ },
248
+ required: ["path", "parent_path", "card"],
249
+ },
250
+ },
251
+ {
252
+ name: "config_move_card",
253
+ description: "Move a card from one container to another.",
254
+ inputSchema: {
255
+ type: "object",
256
+ properties: {
257
+ path: { type: "string" },
258
+ from_path: { type: "string", description: "JSON pointer to the card." },
259
+ to_parent_path: {
260
+ type: "string",
261
+ description: "JSON pointer to the destination array of cards.",
262
+ },
263
+ position: {
264
+ type: ["integer", "string"],
265
+ description: "Index in destination, or 'end' (default).",
266
+ },
267
+ force: { type: "boolean", default: false },
268
+ },
269
+ required: ["path", "from_path", "to_parent_path"],
270
+ },
271
+ },
272
+ {
273
+ name: "config_remove_card",
274
+ description: "Remove a card by JSON pointer.",
275
+ inputSchema: {
276
+ type: "object",
277
+ properties: {
278
+ path: { type: "string" },
279
+ card_path: { type: "string" },
280
+ force: { type: "boolean", default: false },
281
+ },
282
+ required: ["path", "card_path"],
283
+ },
284
+ },
285
+ {
286
+ name: "config_extract_strings",
287
+ description:
288
+ "Scan a plugin's src/ directory for GxP datastore usage and directives (gxp-string, gxp-src, store.getString/getSetting/getAsset/getState calls) and return the extracted keys. Optionally merge them into app-manifest.json (linter-guarded — invalid writes are refused unless force=true).",
289
+ inputSchema: {
290
+ type: "object",
291
+ properties: {
292
+ src_dir: {
293
+ type: "string",
294
+ description:
295
+ "Directory to scan. Default: `src/` inside the project root (cwd).",
296
+ },
297
+ writeTo: {
298
+ type: "string",
299
+ description:
300
+ "Optional absolute/relative path to an app-manifest.json. When provided, extracted entries are merged and the file is rewritten.",
301
+ },
302
+ overwrite: {
303
+ type: "boolean",
304
+ default: false,
305
+ description:
306
+ "If writing, overwrite existing values in the manifest. Default false (only fills in keys that don't yet exist).",
307
+ },
308
+ force: {
309
+ type: "boolean",
310
+ default: false,
311
+ description:
312
+ "Ignore lint errors on the resulting manifest when writing.",
313
+ },
314
+ },
315
+ required: [],
316
+ },
317
+ },
318
+ ]
319
+
320
+ /* -------------------------------- handlers ------------------------------- */
321
+
322
+ async function handleConfigToolCall(name, args = {}) {
323
+ switch (name) {
324
+ case "config_validate": {
325
+ const abs = resolveProjectPath(args.path)
326
+ const result = lintFile(abs)
327
+ return contentResult({
328
+ file: abs,
329
+ ok: result.ok,
330
+ skipped: result.skipped,
331
+ errors: result.errors,
332
+ })
333
+ }
334
+
335
+ case "config_list_field_types": {
336
+ const field = readSchemaFile("field.schema.json")
337
+ const types = field.properties?.type?.enum || []
338
+ return contentResult({ field_types: types })
339
+ }
340
+
341
+ case "config_list_card_types": {
342
+ const card = readSchemaFile("card.schema.json")
343
+ const types = card.properties?.type?.enum || []
344
+ return contentResult({ card_types: types })
345
+ }
346
+
347
+ case "config_get_field_schema": {
348
+ const field = readSchemaFile("field.schema.json")
349
+ const validTypes = field.properties?.type?.enum || []
350
+ if (!validTypes.includes(args.type)) {
351
+ return contentResult({
352
+ error: `Unknown field type: ${args.type}`,
353
+ valid_types: validTypes,
354
+ })
355
+ }
356
+
357
+ // Walk allOf to find any conditional requirements for this type.
358
+ const conditional = []
359
+ for (const rule of field.allOf || []) {
360
+ const cond = rule.if?.properties?.type
361
+ if (!cond) continue
362
+ const matches =
363
+ cond.const === args.type ||
364
+ (Array.isArray(cond.enum) && cond.enum.includes(args.type))
365
+ if (matches && rule.then) {
366
+ conditional.push(rule.then)
367
+ }
368
+ }
369
+
370
+ return contentResult({
371
+ type: args.type,
372
+ base_properties: Object.keys(field.properties),
373
+ required_baseline: field.required,
374
+ conditional_rules: conditional,
375
+ notes:
376
+ "Unlisted properties are allowed. `name` is required for most interactive field types.",
377
+ })
378
+ }
379
+
380
+ case "config_list_cards": {
381
+ const abs = resolveProjectPath(args.path)
382
+ const doc = readJson(abs)
383
+ return contentResult({ file: abs, cards: listCards(doc) })
384
+ }
385
+
386
+ case "config_list_fields": {
387
+ const abs = resolveProjectPath(args.path)
388
+ const doc = readJson(abs)
389
+ return contentResult({
390
+ file: abs,
391
+ card_path: args.card_path,
392
+ fields: listFields(doc, args.card_path),
393
+ })
394
+ }
395
+
396
+ case "config_add_field": {
397
+ const abs = resolveProjectPath(args.path)
398
+ const doc = readJson(abs)
399
+ const card = getByPointer(doc, args.card_path)
400
+ if (!card || card.type !== "fields_list") {
401
+ throw new Error(
402
+ `card_path must reference a fields_list card, got: ${card?.type ?? "nothing"}`,
403
+ )
404
+ }
405
+ const { doc: next, index } = insertAt(
406
+ doc,
407
+ `${args.card_path}/fieldsList`,
408
+ args.field,
409
+ args.position ?? "end",
410
+ )
411
+ const write = writeLinted(abs, next, { force: args.force })
412
+ return contentResult({
413
+ ...write,
414
+ field_path: `${args.card_path}/fieldsList/${index}`,
415
+ })
416
+ }
417
+
418
+ case "config_move_field": {
419
+ const abs = resolveProjectPath(args.path)
420
+ const doc = readJson(abs)
421
+ const targetCard = getByPointer(doc, args.to_card_path)
422
+ if (!targetCard || targetCard.type !== "fields_list") {
423
+ throw new Error(
424
+ `to_card_path must reference a fields_list card, got: ${targetCard?.type ?? "nothing"}`,
425
+ )
426
+ }
427
+ const { doc: next, index } = moveItem(
428
+ doc,
429
+ args.from_path,
430
+ `${args.to_card_path}/fieldsList`,
431
+ args.position ?? "end",
432
+ )
433
+ const write = writeLinted(abs, next, { force: args.force })
434
+ return contentResult({
435
+ ...write,
436
+ new_field_path: `${args.to_card_path}/fieldsList/${index}`,
437
+ })
438
+ }
439
+
440
+ case "config_remove_field": {
441
+ const abs = resolveProjectPath(args.path)
442
+ const doc = readJson(abs)
443
+ const next = deleteByPointer(doc, args.field_path)
444
+ const write = writeLinted(abs, next, { force: args.force })
445
+ return contentResult(write)
446
+ }
447
+
448
+ case "config_add_card": {
449
+ const abs = resolveProjectPath(args.path)
450
+ const doc = readJson(abs)
451
+ const parent = getByPointer(doc, args.parent_path)
452
+ if (!Array.isArray(parent)) {
453
+ throw new Error(
454
+ `parent_path must reference an array of cards, got ${Array.isArray(parent) ? "array" : typeof parent}`,
455
+ )
456
+ }
457
+ const { doc: next, index } = insertAt(
458
+ doc,
459
+ args.parent_path,
460
+ args.card,
461
+ args.position ?? "end",
462
+ )
463
+ const write = writeLinted(abs, next, { force: args.force })
464
+ return contentResult({
465
+ ...write,
466
+ card_path: `${args.parent_path}/${index}`,
467
+ })
468
+ }
469
+
470
+ case "config_move_card": {
471
+ const abs = resolveProjectPath(args.path)
472
+ const doc = readJson(abs)
473
+ const parent = getByPointer(doc, args.to_parent_path)
474
+ if (!Array.isArray(parent)) {
475
+ throw new Error(`to_parent_path must reference an array of cards`)
476
+ }
477
+ const { doc: next, index } = moveItem(
478
+ doc,
479
+ args.from_path,
480
+ args.to_parent_path,
481
+ args.position ?? "end",
482
+ )
483
+ const write = writeLinted(abs, next, { force: args.force })
484
+ return contentResult({
485
+ ...write,
486
+ new_card_path: `${args.to_parent_path}/${index}`,
487
+ })
488
+ }
489
+
490
+ case "config_remove_card": {
491
+ const abs = resolveProjectPath(args.path)
492
+ const doc = readJson(abs)
493
+ const next = deleteByPointer(doc, args.card_path)
494
+ const write = writeLinted(abs, next, { force: args.force })
495
+ return contentResult(write)
496
+ }
497
+
498
+ case "config_extract_strings": {
499
+ const srcDir = resolveProjectPath(args.src_dir || "src")
500
+ if (!fs.existsSync(srcDir)) {
501
+ return contentResult({
502
+ ok: false,
503
+ error: `Source directory not found: ${srcDir}`,
504
+ })
505
+ }
506
+ const extracted = extractUtil.extractConfigFromSource(srcDir)
507
+
508
+ const counts = {
509
+ strings: Object.keys(extracted.strings).length,
510
+ settings: Object.keys(extracted.settings).length,
511
+ assets: Object.keys(extracted.assets).length,
512
+ triggerState: Object.keys(extracted.triggerState).length,
513
+ dependencies: extracted.dependencies.length,
514
+ }
515
+
516
+ const out = { ok: true, src_dir: srcDir, counts, extracted }
517
+
518
+ if (args.writeTo) {
519
+ const manifestAbs = resolveProjectPath(args.writeTo)
520
+ let existing = {}
521
+ if (fs.existsSync(manifestAbs)) {
522
+ existing = readJson(manifestAbs)
523
+ }
524
+ const merged = extractUtil.mergeConfig(existing, extracted, {
525
+ overwrite: !!args.overwrite,
526
+ })
527
+ const write = writeLinted(manifestAbs, merged, {
528
+ force: !!args.force,
529
+ })
530
+ out.write = { ...write, file: manifestAbs }
531
+ }
532
+
533
+ return contentResult(out)
534
+ }
535
+
536
+ default:
537
+ throw new Error(`Unknown config tool: ${name}`)
538
+ }
539
+ }
540
+
541
+ function isConfigTool(name) {
542
+ return CONFIG_TOOLS.some((t) => t.name === name)
543
+ }
544
+
545
+ module.exports = {
546
+ CONFIG_TOOLS,
547
+ handleConfigToolCall,
548
+ isConfigTool,
549
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * MCP tools that surface the GxP documentation at https://docs.gxp.dev to
3
+ * AI assistants.
4
+ */
5
+
6
+ const {
7
+ fetchSitemap,
8
+ fetchPageText,
9
+ searchPages,
10
+ resolvePageUrl,
11
+ } = require("./docs")
12
+
13
+ function contentResult(obj) {
14
+ return {
15
+ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
16
+ }
17
+ }
18
+
19
+ const DOCS_TOOLS = [
20
+ {
21
+ name: "docs_list_pages",
22
+ description:
23
+ "List every page in the GxP docs sitemap (https://docs.gxp.dev). Optionally filter by URL path prefix.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ prefix: {
28
+ type: "string",
29
+ description:
30
+ "Only return URLs whose path starts with this prefix, e.g. '/gx-devtools' or '/guides'.",
31
+ },
32
+ refresh: {
33
+ type: "boolean",
34
+ default: false,
35
+ description: "Bypass the sitemap cache (1-hour TTL).",
36
+ },
37
+ },
38
+ required: [],
39
+ },
40
+ },
41
+ {
42
+ name: "docs_search",
43
+ description:
44
+ "Full-text search across the GxP documentation. Scores each page by term hits (title 3x, heading 2x, body 1x) and returns the top N with snippets. First call per cache window fetches every page; subsequent calls are fast.",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ query: {
49
+ type: "string",
50
+ description:
51
+ "Free-text query. Multi-word queries are AND-scored across all terms.",
52
+ },
53
+ limit: {
54
+ type: "integer",
55
+ default: 10,
56
+ minimum: 1,
57
+ maximum: 50,
58
+ },
59
+ refresh: {
60
+ type: "boolean",
61
+ default: false,
62
+ description: "Bypass all caches and refetch.",
63
+ },
64
+ },
65
+ required: ["query"],
66
+ },
67
+ },
68
+ {
69
+ name: "docs_get_page",
70
+ description:
71
+ "Return the structured text of a single documentation page: title, heading list, full article body. Accepts a full URL or a slug relative to https://docs.gxp.dev.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ url_or_slug: {
76
+ type: "string",
77
+ description:
78
+ "Either 'https://docs.gxp.dev/gx-devtools/cli-reference' or 'gx-devtools/cli-reference'.",
79
+ },
80
+ refresh: {
81
+ type: "boolean",
82
+ default: false,
83
+ },
84
+ },
85
+ required: ["url_or_slug"],
86
+ },
87
+ },
88
+ ]
89
+
90
+ async function handleDocsToolCall(name, args = {}) {
91
+ switch (name) {
92
+ case "docs_list_pages": {
93
+ const urls = await fetchSitemap({ refresh: !!args.refresh })
94
+ const filtered = args.prefix
95
+ ? urls.filter((u) => {
96
+ try {
97
+ return new URL(u).pathname.startsWith(args.prefix)
98
+ } catch {
99
+ return false
100
+ }
101
+ })
102
+ : urls
103
+ return contentResult({ count: filtered.length, urls: filtered })
104
+ }
105
+
106
+ case "docs_search": {
107
+ const results = await searchPages(args.query, {
108
+ limit: args.limit ?? 10,
109
+ refresh: !!args.refresh,
110
+ })
111
+ return contentResult({
112
+ query: args.query,
113
+ count: results.length,
114
+ results,
115
+ })
116
+ }
117
+
118
+ case "docs_get_page": {
119
+ const url = resolvePageUrl(args.url_or_slug)
120
+ const page = await fetchPageText(url, { refresh: !!args.refresh })
121
+ return contentResult({
122
+ url: page.url,
123
+ title: page.title,
124
+ headings: page.headings,
125
+ body: page.body,
126
+ })
127
+ }
128
+
129
+ default:
130
+ throw new Error(`Unknown docs tool: ${name}`)
131
+ }
132
+ }
133
+
134
+ function isDocsTool(name) {
135
+ return DOCS_TOOLS.some((t) => t.name === name)
136
+ }
137
+
138
+ module.exports = {
139
+ DOCS_TOOLS,
140
+ handleDocsToolCall,
141
+ isDocsTool,
142
+ }