@gxp-dev/tools 2.0.70 → 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,456 @@
1
+ /**
2
+ * Extended MCP API tools (Phase 3).
3
+ *
4
+ * Builds on the existing search_api_endpoints / get_endpoint_details to add:
5
+ * - api_list_tags : enumerate all OpenAPI tags
6
+ * - api_list_operation_ids : enumerate operations, optionally filtered by tag
7
+ * - api_get_operation_parameters : deep detail for a single operation id
8
+ * - api_find_endpoints_by_schema : search by request/response field names
9
+ * - api_generate_dependency : build the GxP dependency JSON from a
10
+ * tag + selected operations/events,
11
+ * optionally appending to app-manifest.json
12
+ *
13
+ * The dependency shape mirrors bin/lib/commands/add-dependency.js:
14
+ * { identifier, model, permissionKey, permissions: [], operations: {}, events: {} }
15
+ */
16
+
17
+ const fs = require("fs")
18
+ const path = require("path")
19
+ const { fetchSpec } = require("./specs")
20
+
21
+ /* ---------------------------------- utils --------------------------------- */
22
+
23
+ function contentResult(obj) {
24
+ return {
25
+ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
26
+ }
27
+ }
28
+
29
+ function walkOperations(spec) {
30
+ const out = []
31
+ if (!spec || !spec.paths) return out
32
+ for (const [p, methods] of Object.entries(spec.paths)) {
33
+ for (const [method, op] of Object.entries(methods)) {
34
+ if (typeof op !== "object" || op === null) continue
35
+ if (
36
+ !["get", "post", "put", "patch", "delete", "options", "head"].includes(
37
+ method,
38
+ )
39
+ ) {
40
+ continue
41
+ }
42
+ out.push({ path: p, method: method.toUpperCase(), op })
43
+ }
44
+ }
45
+ return out
46
+ }
47
+
48
+ /**
49
+ * Return the list of distinct field names mentioned anywhere in a JSON-ish
50
+ * object, walking $ref via the components registry shallowly.
51
+ */
52
+ function schemaFieldNames(schema, components, depth = 0, seen = new Set()) {
53
+ if (!schema || depth > 4) return []
54
+ if (schema.$ref && components) {
55
+ const refName = schema.$ref.split("/").pop()
56
+ if (seen.has(refName)) return []
57
+ seen.add(refName)
58
+ const target = components?.schemas?.[refName]
59
+ return schemaFieldNames(target, components, depth + 1, seen)
60
+ }
61
+ const names = []
62
+ if (schema.properties && typeof schema.properties === "object") {
63
+ for (const [name, subSchema] of Object.entries(schema.properties)) {
64
+ names.push(name)
65
+ names.push(...schemaFieldNames(subSchema, components, depth + 1, seen))
66
+ }
67
+ }
68
+ if (schema.items) {
69
+ names.push(...schemaFieldNames(schema.items, components, depth + 1, seen))
70
+ }
71
+ if (Array.isArray(schema.allOf)) {
72
+ for (const sub of schema.allOf) {
73
+ names.push(...schemaFieldNames(sub, components, depth + 1, seen))
74
+ }
75
+ }
76
+ return names
77
+ }
78
+
79
+ function findOperationById(spec, operationId) {
80
+ for (const { path: p, method, op } of walkOperations(spec)) {
81
+ if (op.operationId === operationId) {
82
+ return { path: p, method, op }
83
+ }
84
+ }
85
+ return null
86
+ }
87
+
88
+ /* ------------------------------ tool schemas ------------------------------ */
89
+
90
+ const EXT_API_TOOLS = [
91
+ {
92
+ name: "api_list_tags",
93
+ description:
94
+ "List every tag in the OpenAPI spec with endpoint counts. Use this to discover platform 'models' (attendees, projects, events, etc.) before building a dependency.",
95
+ inputSchema: { type: "object", properties: {}, required: [] },
96
+ },
97
+ {
98
+ name: "api_list_operation_ids",
99
+ description:
100
+ "Enumerate operation IDs in the OpenAPI spec. Optionally filter by tag.",
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {
104
+ tag: {
105
+ type: "string",
106
+ description:
107
+ "Filter to operations under this tag (e.g. 'Attendees'). Omit for all.",
108
+ },
109
+ },
110
+ required: [],
111
+ },
112
+ },
113
+ {
114
+ name: "api_get_operation_parameters",
115
+ description:
116
+ "Look up a single operation by id and return its path, method, parameters, requestBody schema, response schemas, and required permissions.",
117
+ inputSchema: {
118
+ type: "object",
119
+ properties: {
120
+ operationId: {
121
+ type: "string",
122
+ description: "OpenAPI operationId (e.g. 'attendees.index').",
123
+ },
124
+ },
125
+ required: ["operationId"],
126
+ },
127
+ },
128
+ {
129
+ name: "api_find_endpoints_by_schema",
130
+ description:
131
+ "Search endpoints by structural hints: field names present in the request or response bodies, URL path substrings, or HTTP method. Combine any filters — all are AND'd.",
132
+ inputSchema: {
133
+ type: "object",
134
+ properties: {
135
+ request_field: {
136
+ type: "string",
137
+ description:
138
+ "Find endpoints whose request body schema mentions this field name.",
139
+ },
140
+ response_field: {
141
+ type: "string",
142
+ description:
143
+ "Find endpoints whose response schema mentions this field name.",
144
+ },
145
+ path_pattern: {
146
+ type: "string",
147
+ description: "Substring that must appear in the path.",
148
+ },
149
+ method: {
150
+ type: "string",
151
+ description: "HTTP method filter (GET, POST, etc.).",
152
+ },
153
+ tag: {
154
+ type: "string",
155
+ description: "Restrict to one tag.",
156
+ },
157
+ },
158
+ required: [],
159
+ },
160
+ },
161
+ {
162
+ name: "api_generate_dependency",
163
+ description:
164
+ "Build the GxP dependency JSON (for app-manifest.json dependencies[]) from a tag and an explicit list of operationIds and/or asyncapi event names. Optionally append it to an app-manifest.json file.",
165
+ inputSchema: {
166
+ type: "object",
167
+ properties: {
168
+ identifier: {
169
+ type: "string",
170
+ description:
171
+ "Short slug used by plugin code to reference the dependency (e.g. 'attendees').",
172
+ },
173
+ tag: {
174
+ type: "string",
175
+ description: "OpenAPI tag name — becomes the dependency's 'model'.",
176
+ },
177
+ operationIds: {
178
+ type: "array",
179
+ items: { type: "string" },
180
+ description:
181
+ "Operation IDs to include. Omit to include every operation under the tag.",
182
+ },
183
+ eventNames: {
184
+ type: "array",
185
+ items: { type: "string" },
186
+ description:
187
+ "AsyncAPI event names to wire up in the dependency's events map.",
188
+ },
189
+ writeTo: {
190
+ type: "string",
191
+ description:
192
+ "Optional path to an app-manifest.json. When provided, the dependency is appended/replaced in manifest.dependencies[] and the file is rewritten.",
193
+ },
194
+ },
195
+ required: ["identifier", "tag"],
196
+ },
197
+ },
198
+ ]
199
+
200
+ /* ---------------------------------- core ---------------------------------- */
201
+
202
+ async function listTags() {
203
+ const spec = await fetchSpec("openapi")
204
+ const tagMeta = new Map()
205
+ for (const t of spec.tags || []) {
206
+ tagMeta.set(t.name, {
207
+ name: t.name,
208
+ description: t.description || "",
209
+ pathCount: 0,
210
+ })
211
+ }
212
+ for (const { op } of walkOperations(spec)) {
213
+ for (const tag of op.tags || []) {
214
+ if (!tagMeta.has(tag)) {
215
+ tagMeta.set(tag, { name: tag, description: "", pathCount: 0 })
216
+ }
217
+ tagMeta.get(tag).pathCount++
218
+ }
219
+ }
220
+ return Array.from(tagMeta.values()).sort((a, b) =>
221
+ a.name.localeCompare(b.name),
222
+ )
223
+ }
224
+
225
+ async function listOperationIds(tag) {
226
+ const spec = await fetchSpec("openapi")
227
+ const out = []
228
+ for (const { path: p, method, op } of walkOperations(spec)) {
229
+ if (!op.operationId) continue
230
+ if (tag && !(op.tags || []).includes(tag)) continue
231
+ out.push({
232
+ operationId: op.operationId,
233
+ method,
234
+ path: p,
235
+ tags: op.tags || [],
236
+ summary: op.summary || "",
237
+ })
238
+ }
239
+ return out
240
+ }
241
+
242
+ async function getOperationParameters(operationId) {
243
+ const spec = await fetchSpec("openapi")
244
+ const found = findOperationById(spec, operationId)
245
+ if (!found) return null
246
+
247
+ const { path: p, method, op } = found
248
+ const permission =
249
+ op["x-permission"] ||
250
+ op["x-permissionKey"] ||
251
+ op.security?.[0]?.permission ||
252
+ null
253
+
254
+ return {
255
+ operationId,
256
+ path: p,
257
+ method,
258
+ tags: op.tags || [],
259
+ summary: op.summary || "",
260
+ description: op.description || "",
261
+ parameters: op.parameters || [],
262
+ requestBody: op.requestBody || null,
263
+ responses: op.responses || {},
264
+ permission,
265
+ security: op.security || spec.security || [],
266
+ }
267
+ }
268
+
269
+ async function findEndpointsBySchema(filters) {
270
+ const spec = await fetchSpec("openapi")
271
+ const components = spec.components || {}
272
+ const {
273
+ request_field: reqField,
274
+ response_field: respField,
275
+ path_pattern: pathPattern,
276
+ method: methodFilter,
277
+ tag,
278
+ } = filters || {}
279
+
280
+ const out = []
281
+ for (const { path: p, method, op } of walkOperations(spec)) {
282
+ if (methodFilter && methodFilter.toUpperCase() !== method) continue
283
+ if (pathPattern && !p.includes(pathPattern)) continue
284
+ if (tag && !(op.tags || []).includes(tag)) continue
285
+
286
+ if (reqField) {
287
+ const reqSchema = op.requestBody?.content?.["application/json"]?.schema
288
+ const fields = schemaFieldNames(reqSchema, components)
289
+ if (!fields.includes(reqField)) continue
290
+ }
291
+ if (respField) {
292
+ // Prefer a 2xx response; fall back to any.
293
+ const responses = op.responses || {}
294
+ const preferred =
295
+ responses["200"] ||
296
+ responses["201"] ||
297
+ responses["204"] ||
298
+ Object.values(responses)[0]
299
+ const respSchema = preferred?.content?.["application/json"]?.schema
300
+ const fields = schemaFieldNames(respSchema, components)
301
+ if (!fields.includes(respField)) continue
302
+ }
303
+
304
+ out.push({
305
+ operationId: op.operationId || null,
306
+ method,
307
+ path: p,
308
+ tags: op.tags || [],
309
+ summary: op.summary || "",
310
+ })
311
+ }
312
+ return out
313
+ }
314
+
315
+ async function generateDependency({
316
+ identifier,
317
+ tag,
318
+ operationIds,
319
+ eventNames,
320
+ writeTo,
321
+ }) {
322
+ const openapi = await fetchSpec("openapi")
323
+
324
+ // Discover operations under the tag.
325
+ const scoped = []
326
+ for (const { path: p, method, op } of walkOperations(openapi)) {
327
+ if (!(op.tags || []).includes(tag)) continue
328
+ if (operationIds && operationIds.length > 0) {
329
+ if (!operationIds.includes(op.operationId)) continue
330
+ }
331
+ scoped.push({ path: p, method, op })
332
+ }
333
+
334
+ if (scoped.length === 0) {
335
+ return {
336
+ ok: false,
337
+ error: `No operations found for tag="${tag}"${
338
+ operationIds
339
+ ? ` matching operationIds=${JSON.stringify(operationIds)}`
340
+ : ""
341
+ }.`,
342
+ }
343
+ }
344
+
345
+ const operations = {}
346
+ const permissions = new Set()
347
+ let permissionKey = null
348
+ for (const { path: p, method, op } of scoped) {
349
+ if (!op.operationId) continue
350
+ const cleanOpId = op.operationId.replace(/^portal\.v1\.project\./, "")
351
+ operations[cleanOpId] = `${method.toLowerCase()}:${p}`
352
+
353
+ const perm = op["x-permission"] || op.security?.[0]?.permission || null
354
+ if (perm) permissions.add(perm)
355
+
356
+ const key = op["x-permission-key"] || op["x-permissionKey"] || null
357
+ if (!permissionKey && key) permissionKey = key
358
+ }
359
+
360
+ const events = {}
361
+ if (Array.isArray(eventNames)) {
362
+ for (const name of eventNames) {
363
+ events[name] = name
364
+ }
365
+ }
366
+
367
+ const dependency = {
368
+ identifier,
369
+ model: tag,
370
+ permissionKey,
371
+ permissions: Array.from(permissions).sort(),
372
+ operations,
373
+ events,
374
+ }
375
+
376
+ const result = { ok: true, dependency, wrote: false }
377
+
378
+ if (writeTo) {
379
+ const absPath = path.resolve(process.cwd(), writeTo)
380
+ let manifest = { dependencies: [] }
381
+ if (fs.existsSync(absPath)) {
382
+ manifest = JSON.parse(fs.readFileSync(absPath, "utf-8"))
383
+ }
384
+ if (!Array.isArray(manifest.dependencies)) {
385
+ manifest.dependencies = []
386
+ }
387
+ const existingIdx = manifest.dependencies.findIndex(
388
+ (d) => d.identifier === identifier,
389
+ )
390
+ if (existingIdx >= 0) {
391
+ manifest.dependencies[existingIdx] = dependency
392
+ result.replaced = true
393
+ } else {
394
+ manifest.dependencies.push(dependency)
395
+ result.replaced = false
396
+ }
397
+ fs.writeFileSync(absPath, JSON.stringify(manifest, null, "\t"))
398
+ result.wrote = true
399
+ result.file = absPath
400
+ }
401
+
402
+ return result
403
+ }
404
+
405
+ /* --------------------------------- dispatch ------------------------------- */
406
+
407
+ async function handleExtApiToolCall(name, args = {}) {
408
+ switch (name) {
409
+ case "api_list_tags":
410
+ return contentResult({ tags: await listTags() })
411
+
412
+ case "api_list_operation_ids":
413
+ return contentResult({
414
+ operations: await listOperationIds(args.tag),
415
+ })
416
+
417
+ case "api_get_operation_parameters": {
418
+ const detail = await getOperationParameters(args.operationId)
419
+ if (!detail) {
420
+ return contentResult({
421
+ error: `Operation not found: ${args.operationId}`,
422
+ })
423
+ }
424
+ return contentResult(detail)
425
+ }
426
+
427
+ case "api_find_endpoints_by_schema":
428
+ return contentResult({
429
+ results: await findEndpointsBySchema(args || {}),
430
+ })
431
+
432
+ case "api_generate_dependency":
433
+ return contentResult(await generateDependency(args))
434
+
435
+ default:
436
+ throw new Error(`Unknown ext api tool: ${name}`)
437
+ }
438
+ }
439
+
440
+ function isExtApiTool(name) {
441
+ return EXT_API_TOOLS.some((t) => t.name === name)
442
+ }
443
+
444
+ module.exports = {
445
+ EXT_API_TOOLS,
446
+ handleExtApiToolCall,
447
+ isExtApiTool,
448
+ // Exported for testing
449
+ walkOperations,
450
+ schemaFieldNames,
451
+ listTags,
452
+ listOperationIds,
453
+ getOperationParameters,
454
+ findEndpointsBySchema,
455
+ generateDependency,
456
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * JSON-pointer operations + GxP-aware walkers used by the MCP config tools.
3
+ *
4
+ * Paths are RFC-6901 JSON pointers:
5
+ * "/additionalTabs/0/cards/1/fieldsList/2"
6
+ * Empty or "/" means "the whole document".
7
+ *
8
+ * All functions are pure: they operate on a parsed doc and return either a
9
+ * value (readers) or a new doc (writers). No file IO here.
10
+ */
11
+
12
+ function parsePointer(pointer) {
13
+ if (pointer === "" || pointer === "/") {
14
+ return []
15
+ }
16
+ if (!pointer.startsWith("/")) {
17
+ throw new Error(`Invalid JSON pointer (must start with "/"): ${pointer}`)
18
+ }
19
+ return pointer
20
+ .slice(1)
21
+ .split("/")
22
+ .map((segment) =>
23
+ decodeURIComponent(segment.replace(/~1/g, "/").replace(/~0/g, "~")),
24
+ )
25
+ }
26
+
27
+ function encodeSegment(segment) {
28
+ return String(segment).replace(/~/g, "~0").replace(/\//g, "~1")
29
+ }
30
+
31
+ function buildPointer(segments) {
32
+ if (!segments.length) return ""
33
+ return "/" + segments.map(encodeSegment).join("/")
34
+ }
35
+
36
+ function isIndex(segment) {
37
+ return /^\d+$/.test(segment)
38
+ }
39
+
40
+ function getByPointer(doc, pointer) {
41
+ const segments = parsePointer(pointer)
42
+ let cur = doc
43
+ for (const seg of segments) {
44
+ if (cur === null || cur === undefined) return undefined
45
+ if (Array.isArray(cur)) {
46
+ if (!isIndex(seg)) return undefined
47
+ cur = cur[Number(seg)]
48
+ } else if (typeof cur === "object") {
49
+ cur = cur[seg]
50
+ } else {
51
+ return undefined
52
+ }
53
+ }
54
+ return cur
55
+ }
56
+
57
+ function getParent(doc, pointer) {
58
+ const segments = parsePointer(pointer)
59
+ if (!segments.length) {
60
+ throw new Error("Cannot get parent of root")
61
+ }
62
+ const last = segments[segments.length - 1]
63
+ const parent = getByPointer(doc, buildPointer(segments.slice(0, -1)))
64
+ return { parent, key: last, isIndex: isIndex(last) }
65
+ }
66
+
67
+ /**
68
+ * Produce a deep-cloned document with the value at `pointer` set to `value`.
69
+ * Throws if the parent doesn't exist.
70
+ */
71
+ function setByPointer(doc, pointer, value) {
72
+ const clone = structuredClone(doc)
73
+ const segments = parsePointer(pointer)
74
+ if (!segments.length) {
75
+ return value
76
+ }
77
+ let cur = clone
78
+ for (let i = 0; i < segments.length - 1; i++) {
79
+ const seg = segments[i]
80
+ if (cur[seg] === undefined || cur[seg] === null) {
81
+ throw new Error(
82
+ `Path does not exist at "${buildPointer(segments.slice(0, i + 1))}"`,
83
+ )
84
+ }
85
+ cur = cur[seg]
86
+ }
87
+ const last = segments[segments.length - 1]
88
+ if (Array.isArray(cur) && isIndex(last)) {
89
+ cur[Number(last)] = value
90
+ } else {
91
+ cur[last] = value
92
+ }
93
+ return clone
94
+ }
95
+
96
+ function deleteByPointer(doc, pointer) {
97
+ const clone = structuredClone(doc)
98
+ const segments = parsePointer(pointer)
99
+ if (!segments.length) {
100
+ throw new Error("Cannot delete root")
101
+ }
102
+ let cur = clone
103
+ for (let i = 0; i < segments.length - 1; i++) {
104
+ cur = cur[segments[i]]
105
+ if (cur === undefined) {
106
+ throw new Error(`Path does not exist: ${pointer}`)
107
+ }
108
+ }
109
+ const last = segments[segments.length - 1]
110
+ if (Array.isArray(cur) && isIndex(last)) {
111
+ cur.splice(Number(last), 1)
112
+ } else {
113
+ delete cur[last]
114
+ }
115
+ return clone
116
+ }
117
+
118
+ /**
119
+ * Insert a value into an array at `pointer` (must point to an array) at the
120
+ * given position. `position` may be an integer or "end".
121
+ */
122
+ function insertAt(doc, arrayPointer, item, position = "end") {
123
+ const clone = structuredClone(doc)
124
+ const arr = getByPointer(clone, arrayPointer)
125
+ if (!Array.isArray(arr)) {
126
+ throw new Error(`Pointer must reference an array: ${arrayPointer}`)
127
+ }
128
+ if (position === "end" || position === undefined) {
129
+ arr.push(item)
130
+ return { doc: clone, index: arr.length - 1 }
131
+ }
132
+ const idx = Number(position)
133
+ if (!Number.isInteger(idx) || idx < 0 || idx > arr.length) {
134
+ throw new Error(`Invalid position: ${position}`)
135
+ }
136
+ arr.splice(idx, 0, item)
137
+ return { doc: clone, index: idx }
138
+ }
139
+
140
+ /**
141
+ * Move an item from one pointer to another. Both must point to items inside
142
+ * arrays; targetArray is the pointer to the destination array.
143
+ */
144
+ function moveItem(doc, fromPointer, targetArrayPointer, position = "end") {
145
+ const item = getByPointer(doc, fromPointer)
146
+ if (item === undefined) {
147
+ throw new Error(`Source does not exist: ${fromPointer}`)
148
+ }
149
+ const withoutItem = deleteByPointer(doc, fromPointer)
150
+
151
+ // If the move is within the same array and we're shifting forward, the
152
+ // source-removal may have reindexed the target. We recompute here: parse
153
+ // the target array pointer and verify it still exists after removal.
154
+ const targetArr = getByPointer(withoutItem, targetArrayPointer)
155
+ if (!Array.isArray(targetArr)) {
156
+ throw new Error(`Target must be an array: ${targetArrayPointer}`)
157
+ }
158
+ return insertAt(withoutItem, targetArrayPointer, item, position)
159
+ }
160
+
161
+ /**
162
+ * Recursively walk a configuration document and return a flat list of all
163
+ * cards with their JSON pointer, type, title, and a summary of children.
164
+ *
165
+ * Cards are discovered under:
166
+ * additionalTabs[] (root array of cards)
167
+ * <card>.cards[] (card_list)
168
+ * <card>.tabsList[].cards[] (tabs_list)
169
+ */
170
+ function listCards(doc) {
171
+ const out = []
172
+
173
+ function walk(node, pointer) {
174
+ if (!node || typeof node !== "object") return
175
+
176
+ // If node looks like a card (has `type`), record it.
177
+ if (typeof node.type === "string") {
178
+ out.push({
179
+ path: pointer,
180
+ type: node.type,
181
+ title: node.title ?? null,
182
+ tabId: node.tabId ?? null,
183
+ fieldCount: Array.isArray(node.fieldsList) ? node.fieldsList.length : 0,
184
+ })
185
+ }
186
+
187
+ if (Array.isArray(node.cards)) {
188
+ node.cards.forEach((c, i) => walk(c, `${pointer}/cards/${i}`))
189
+ }
190
+ if (Array.isArray(node.tabsList)) {
191
+ node.tabsList.forEach((tab, i) => {
192
+ if (Array.isArray(tab.cards)) {
193
+ tab.cards.forEach((c, j) =>
194
+ walk(c, `${pointer}/tabsList/${i}/cards/${j}`),
195
+ )
196
+ }
197
+ })
198
+ }
199
+ }
200
+
201
+ if (Array.isArray(doc?.additionalTabs)) {
202
+ doc.additionalTabs.forEach((c, i) => walk(c, `/additionalTabs/${i}`))
203
+ }
204
+ return out
205
+ }
206
+
207
+ /**
208
+ * List all fields under a fields_list card, with their JSON pointer.
209
+ */
210
+ function listFields(doc, cardPointer) {
211
+ const card = getByPointer(doc, cardPointer)
212
+ if (!card || !Array.isArray(card.fieldsList)) {
213
+ return []
214
+ }
215
+ return card.fieldsList.map((f, i) => ({
216
+ path: `${cardPointer}/fieldsList/${i}`,
217
+ type: f.type,
218
+ name: f.name ?? null,
219
+ label: f.label ?? null,
220
+ }))
221
+ }
222
+
223
+ module.exports = {
224
+ parsePointer,
225
+ buildPointer,
226
+ getByPointer,
227
+ getParent,
228
+ setByPointer,
229
+ deleteByPointer,
230
+ insertAt,
231
+ moveItem,
232
+ listCards,
233
+ listFields,
234
+ }