@gavdi/cap-mcp 1.3.2 → 1.4.1

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.
package/README.md CHANGED
@@ -70,7 +70,7 @@ service CatalogService {
70
70
  @mcp: {
71
71
  name: 'books',
72
72
  description: 'Book catalog with search and filtering',
73
- resource: ['filter', 'orderby', 'select', 'top', 'skip']
73
+ resource: ['filter', 'orderby', 'select', 'top', 'skip', 'expand']
74
74
  }
75
75
  entity Books as projection on my.Books;
76
76
 
@@ -117,6 +117,7 @@ This plugin transforms your annotated CAP services into a fully functional MCP s
117
117
  - **📊 Resources**: Expose CAP entities as MCP resources with OData v4 query capabilities
118
118
  - **🔧 Tools**: Convert CAP functions and actions into executable MCP tools
119
119
  - **🧩 Entity Wrappers (optional)**: Expose CAP entities as tools (`query`, `get`, and optionally `create`, `update`) for LLM tool use while keeping resources intact
120
+ - **🔗 Deep Insert**: Create parent and child entities in a single operation with `@mcp.deepInsert` annotation
120
121
  - **💡 Prompts**: Define reusable prompt templates for AI interactions
121
122
  - **⚡ Elicitation**: Request user confirmation or input parameters before tool execution
122
123
  - **🔄 Auto-generation**: Automatically creates MCP server endpoints based on annotations
@@ -150,7 +151,8 @@ service CatalogService {
150
151
  'orderby',
151
152
  'select',
152
153
  'skip',
153
- 'top'
154
+ 'top',
155
+ 'expand'
154
156
  ]
155
157
  }
156
158
  entity Books as projection on my.Books;
@@ -174,7 +176,7 @@ service CatalogService {
174
176
  ```
175
177
 
176
178
  **Generated MCP Resource Capabilities:**
177
- - **OData v4 Query Support**: `$filter`, `$orderby`, `$top`, `$skip`, `$select`
179
+ - **OData v4 Query Support**: `$filter`, `$orderby`, `$top`, `$skip`, `$select`, `$expand`
178
180
  - **Natural Language Queries**: "Find books by Stephen King with stock > 20"
179
181
  - **Dynamic Filtering**: Complex filter expressions using OData syntax
180
182
  - **Flexible Selection**: Choose specific fields and sort orders
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MCP_OMIT_PROP_KEY = exports.MCP_HINT_ELEMENT = exports.DEFAULT_ALL_RESOURCE_OPTIONS = exports.MCP_ANNOTATION_MAPPING = exports.MCP_ANNOTATION_KEY = void 0;
3
+ exports.MCP_DEEP_INSERT_KEY = exports.MCP_OMIT_PROP_KEY = exports.MCP_HINT_ELEMENT = exports.DEFAULT_ALL_RESOURCE_OPTIONS = exports.MCP_ANNOTATION_MAPPING = exports.MCP_ANNOTATION_KEY = void 0;
4
4
  /**
5
5
  * MCP annotation constants and default configurations
6
6
  * Defines the standard annotation keys and default values used throughout the plugin
@@ -29,6 +29,7 @@ exports.MCP_ANNOTATION_MAPPING = new Map([
29
29
  ["@mcp.wrap.hint.update", "wrap.hint.update"],
30
30
  ["@mcp.wrap.hint.delete", "wrap.hint.delete"],
31
31
  ["@mcp.elicit", "elicit"],
32
+ ["@mcp.deepInsert", "deepInsert"],
32
33
  ["@requires", "requires"],
33
34
  ["@restrict", "restrict"],
34
35
  ]);
@@ -43,6 +44,7 @@ exports.DEFAULT_ALL_RESOURCE_OPTIONS = new Set([
43
44
  "top",
44
45
  "skip",
45
46
  "select",
47
+ "expand",
46
48
  ]);
47
49
  /**
48
50
  * Hint key for annotations made on specific properties/elements
@@ -52,3 +54,7 @@ exports.MCP_HINT_ELEMENT = "@mcp.hint";
52
54
  * MCP omit property annotation key
53
55
  */
54
56
  exports.MCP_OMIT_PROP_KEY = "@mcp.omit";
57
+ /**
58
+ * MCP deep insert annotation key for element-level deep insert references
59
+ */
60
+ exports.MCP_DEEP_INSERT_KEY = "@mcp.deepInsert";
@@ -138,7 +138,35 @@ function constructResourceAnnotation(serviceName, target, annotations, definitio
138
138
  .map(([k, _]) => k));
139
139
  const { properties, resourceKeys, propertyHints } = (0, utils_1.parseResourceElements)(definition, model);
140
140
  const restrictions = (0, utils_1.parseCdsRestrictions)(annotations.restrict, annotations.requires);
141
- return new structures_1.McpResourceAnnotation(annotations.name, annotations.description, target, serviceName, functionalities, properties, resourceKeys, foreignKeys, annotations.wrap, restrictions, computedFields, propertyHints, omittedFields);
141
+ const deepInsertRefs = (0, utils_1.parseDeepInsertRefs)(definition);
142
+ // Build association safe columns map
143
+ const associationSafeColumns = new Map();
144
+ const entityDef = model.definitions?.[entityTarget];
145
+ if (entityDef?.elements) {
146
+ for (const [propName, propDef] of Object.entries(entityDef.elements)) {
147
+ const cdsType = String(propDef.type || "");
148
+ if (!cdsType.toLowerCase().includes("association"))
149
+ continue;
150
+ // Get target entity from the association definition
151
+ const assocTarget = propDef.target;
152
+ if (!assocTarget)
153
+ continue;
154
+ const targetDef = model.definitions?.[assocTarget];
155
+ if (!targetDef?.elements)
156
+ continue;
157
+ // Find omitted fields on the target entity
158
+ const targetOmitted = new Set(Object.entries(targetDef.elements)
159
+ .filter(([_, v]) => v[constants_1.MCP_OMIT_PROP_KEY])
160
+ .map(([k]) => k));
161
+ // If target has omitted fields, compute safe columns
162
+ if (targetOmitted.size > 0) {
163
+ const safeColumns = Object.keys(targetDef.elements).filter((k) => !targetOmitted.has(k));
164
+ associationSafeColumns.set(propName, safeColumns);
165
+ }
166
+ // If no omitted fields, don't add to map (will use '*' as fallback)
167
+ }
168
+ }
169
+ return new structures_1.McpResourceAnnotation(annotations.name, annotations.description, target, serviceName, functionalities, properties, resourceKeys, foreignKeys, annotations.wrap, restrictions, computedFields, propertyHints, omittedFields, deepInsertRefs, associationSafeColumns);
142
170
  }
143
171
  /**
144
172
  * Constructs a tool annotation from parsed annotation data
@@ -98,6 +98,10 @@ class McpResourceAnnotation extends McpAnnotation {
98
98
  _computedFields;
99
99
  /** List of omitted fields */
100
100
  _omittedFields;
101
+ /** Map of association property names to target entity names for deep insert */
102
+ _deepInsertRefs;
103
+ /** Map of association name → target entity's safe columns (excluding omitted) */
104
+ _associationSafeColumns;
101
105
  /**
102
106
  * Creates a new MCP resource annotation
103
107
  * @param name - Unique identifier for this resource
@@ -113,8 +117,10 @@ class McpResourceAnnotation extends McpAnnotation {
113
117
  * @param computedFields - Optional set of fields that are computed and should be ignored in create scenarios
114
118
  * @param propertyHints - Optional map of hints for specific properties on resource
115
119
  * @param omittedFields - Optional set of fields that should be omitted from MCP entity
120
+ * @param deepInsertRefs - Optional map of association property names to target entity names for deep insert
121
+ * @param associationSafeColumns - Optional map of association names to their safe columns (pre-computed)
116
122
  */
117
- constructor(name, description, target, serviceName, functionalities, properties, resourceKeys, foreignKeys, wrap, restrictions, computedFields, propertyHints, omittedFields) {
123
+ constructor(name, description, target, serviceName, functionalities, properties, resourceKeys, foreignKeys, wrap, restrictions, computedFields, propertyHints, omittedFields, deepInsertRefs, associationSafeColumns) {
118
124
  super(name, description, target, serviceName, restrictions ?? [], propertyHints ?? new Map());
119
125
  this._functionalities = functionalities;
120
126
  this._properties = properties;
@@ -123,6 +129,8 @@ class McpResourceAnnotation extends McpAnnotation {
123
129
  this._foreignKeys = foreignKeys;
124
130
  this._computedFields = computedFields;
125
131
  this._omittedFields = omittedFields;
132
+ this._deepInsertRefs = deepInsertRefs ?? new Map();
133
+ this._associationSafeColumns = associationSafeColumns;
126
134
  }
127
135
  /**
128
136
  * Gets the set of enabled OData query functionalities
@@ -170,6 +178,31 @@ class McpResourceAnnotation extends McpAnnotation {
170
178
  get omittedFields() {
171
179
  return this._omittedFields;
172
180
  }
181
+ /**
182
+ * Gets the map of association property names to target entity names for deep insert
183
+ * @returns Map of association names to entity names for deep insert schema references
184
+ */
185
+ get deepInsertRefs() {
186
+ return this._deepInsertRefs;
187
+ }
188
+ /**
189
+ * Gets the list of safe (non-omitted) columns for the main entity.
190
+ * Returns ['*'] if no fields are omitted, otherwise returns explicit column list.
191
+ */
192
+ get safeColumns() {
193
+ if (!this._omittedFields || this._omittedFields.size === 0) {
194
+ return ["*"]; // No omitted fields, safe to use star
195
+ }
196
+ return Array.from(this._properties.keys()).filter((k) => !this._omittedFields?.has(k));
197
+ }
198
+ /**
199
+ * Gets safe columns for an association target, if available.
200
+ * Returns undefined if the association has no omitted fields (use '*' as fallback).
201
+ * @param assocName - Name of the association property
202
+ */
203
+ getAssociationSafeColumns(assocName) {
204
+ return this._associationSafeColumns?.get(assocName);
205
+ }
173
206
  }
174
207
  exports.McpResourceAnnotation = McpResourceAnnotation;
175
208
  /**
@@ -12,6 +12,7 @@ exports.parseResourceElements = parseResourceElements;
12
12
  exports.parseOperationElements = parseOperationElements;
13
13
  exports.parseEntityKeys = parseEntityKeys;
14
14
  exports.parseCdsRestrictions = parseCdsRestrictions;
15
+ exports.parseDeepInsertRefs = parseDeepInsertRefs;
15
16
  const constants_1 = require("./constants");
16
17
  const logger_1 = require("../logger");
17
18
  /**
@@ -345,3 +346,29 @@ function translateOperationRestriction(restrictionType) {
345
346
  return [restrictionType];
346
347
  }
347
348
  }
349
+ /**
350
+ * Parses @mcp.deepInsert annotations from entity elements
351
+ * @param definition - The entity definition to parse
352
+ * @returns Map of association property names to target entity names for deep insert
353
+ */
354
+ function parseDeepInsertRefs(definition) {
355
+ const deepInsertRefs = new Map();
356
+ if (!definition?.elements)
357
+ return deepInsertRefs;
358
+ for (const [propName, element] of Object.entries(definition.elements)) {
359
+ // Check if element has @mcp.deepInsert annotation (boolean or truthy)
360
+ const hasDeepInsert = element[constants_1.MCP_DEEP_INSERT_KEY];
361
+ if (hasDeepInsert) {
362
+ // Infer target from association/composition definition
363
+ const targetEntity = element.target;
364
+ if (targetEntity) {
365
+ deepInsertRefs.set(propName, targetEntity);
366
+ logger_1.LOGGER.debug(`Found @mcp.deepInsert on ${propName} -> inferred target: ${targetEntity}`);
367
+ }
368
+ else {
369
+ logger_1.LOGGER.warn(`@mcp.deepInsert on ${propName} but no target entity found`);
370
+ }
371
+ }
372
+ }
373
+ return deepInsertRefs;
374
+ }
@@ -211,6 +211,10 @@ function registerQueryTool(resAnno, server, authEnabled) {
211
211
  .optional()
212
212
  .transform((val) => (val && val.length > 0 ? val : undefined)),
213
213
  explain: zod_1.z.boolean().optional(),
214
+ expand: zod_1.z
215
+ .union([zod_1.z.string(), zod_1.z.array(zod_1.z.string())])
216
+ .optional()
217
+ .describe('Expand associations: "*" for all, or array of association names'),
214
218
  })
215
219
  .strict();
216
220
  const inputSchema = {
@@ -223,6 +227,7 @@ function registerQueryTool(resAnno, server, authEnabled) {
223
227
  return: inputZod.shape.return,
224
228
  aggregate: inputZod.shape.aggregate,
225
229
  explain: inputZod.shape.explain,
230
+ expand: inputZod.shape.expand,
226
231
  };
227
232
  const hint = constructHintMessage(resAnno, "query");
228
233
  const desc = `Resource description: ${resAnno.description}. ${buildEnhancedQueryDescription(resAnno)} CRITICAL: Use foreign key fields (e.g., author_ID) for associations - association names (e.g., author) won't work in filters.` +
@@ -348,8 +353,19 @@ function registerCreateTool(resAnno, server, authEnabled) {
348
353
  for (const [propName, cdsType] of resAnno.properties.entries()) {
349
354
  const isAssociation = String(cdsType).toLowerCase().includes("association");
350
355
  const isComputed = resAnno.computedFields?.has(propName);
351
- if (isAssociation || isComputed) {
352
- // Association keys are supplied directly from model loading as of v1.1.2
356
+ // Check if this association is marked for deep insert
357
+ if (isAssociation) {
358
+ if (resAnno.deepInsertRefs.has(propName)) {
359
+ // This association has @mcp.deepInsert annotation
360
+ const targetEntityName = resAnno.deepInsertRefs.get(propName);
361
+ inputSchema[propName] = (0, utils_2.buildDeepInsertZodType)(targetEntityName)
362
+ .optional()
363
+ .describe(`Deep insert array for ${propName}. ${resAnno.propertyHints.get(propName) ?? ""}`);
364
+ }
365
+ // Skip regular associations (no deep insert)
366
+ continue;
367
+ }
368
+ if (isComputed) {
353
369
  continue;
354
370
  }
355
371
  inputSchema[propName] = (0, utils_2.determineMcpParameterType)(cdsType, propName, `${resAnno.serviceName}.${resAnno.target}`)
@@ -377,18 +393,23 @@ function registerCreateTool(resAnno, server, authEnabled) {
377
393
  .toLowerCase()
378
394
  .includes("association");
379
395
  if (isAssociation) {
396
+ // Check if this association is marked for deep insert
397
+ if (resAnno.deepInsertRefs.has(propName)) {
398
+ // Pass through the nested array for deep insert
399
+ if (args[propName] !== undefined && Array.isArray(args[propName])) {
400
+ data[propName] = args[propName];
401
+ }
402
+ continue;
403
+ }
404
+ // Regular association - use foreign key
380
405
  const fkName = `${propName}_ID`;
381
406
  if (args[fkName] !== undefined) {
382
- const val = args[fkName];
383
- data[fkName] =
384
- typeof val === "string" && /^\d+$/.test(val) ? Number(val) : val;
407
+ data[fkName] = args[fkName];
385
408
  }
386
409
  continue;
387
410
  }
388
411
  if (args[propName] !== undefined) {
389
- const val = args[propName];
390
- data[propName] =
391
- typeof val === "string" && /^\d+$/.test(val) ? Number(val) : val;
412
+ data[propName] = args[propName];
392
413
  }
393
414
  }
394
415
  const tx = svc.tx({ user: (0, utils_1.getAccessRights)(authEnabled) });
@@ -438,8 +459,19 @@ function registerUpdateTool(resAnno, server, authEnabled) {
438
459
  continue;
439
460
  const isComputed = resAnno.computedFields?.has(propName);
440
461
  const isAssociation = String(cdsType).toLowerCase().includes("association");
441
- if (isAssociation || isComputed) {
442
- // Association keys are supplied directly from model loading as of v1.1.2
462
+ if (isComputed) {
463
+ continue;
464
+ }
465
+ // Check if this association is marked for deep insert
466
+ if (isAssociation) {
467
+ if (resAnno.deepInsertRefs.has(propName)) {
468
+ // This association has @mcp.deepInsert annotation
469
+ const targetEntityName = resAnno.deepInsertRefs.get(propName);
470
+ inputSchema[propName] = (0, utils_2.buildDeepInsertZodType)(targetEntityName)
471
+ .optional()
472
+ .describe(`Deep update array for ${propName}. ${resAnno.propertyHints.get(propName) ?? ""}`);
473
+ }
474
+ // Skip regular associations (no deep insert)
443
475
  continue;
444
476
  }
445
477
  inputSchema[propName] = (0, utils_2.determineMcpParameterType)(cdsType, propName, `${resAnno.serviceName}.${resAnno.target}`)
@@ -480,18 +512,23 @@ function registerUpdateTool(resAnno, server, authEnabled) {
480
512
  .toLowerCase()
481
513
  .includes("association");
482
514
  if (isAssociation) {
515
+ // Check if this association is marked for deep insert
516
+ if (resAnno.deepInsertRefs.has(propName)) {
517
+ // Pass through the nested array for deep update
518
+ if (args[propName] !== undefined && Array.isArray(args[propName])) {
519
+ updates[propName] = args[propName];
520
+ }
521
+ continue;
522
+ }
523
+ // Regular association - use foreign key
483
524
  const fkName = `${propName}_ID`;
484
525
  if (args[fkName] !== undefined) {
485
- const val = args[fkName];
486
- updates[fkName] =
487
- typeof val === "string" && /^\d+$/.test(val) ? Number(val) : val;
526
+ updates[fkName] = args[fkName];
488
527
  }
489
528
  continue;
490
529
  }
491
530
  if (args[propName] !== undefined) {
492
- const val = args[propName];
493
- updates[propName] =
494
- typeof val === "string" && /^\d+$/.test(val) ? Number(val) : val;
531
+ updates[propName] = args[propName];
495
532
  }
496
533
  }
497
534
  if (Object.keys(updates).length === 0) {
@@ -609,7 +646,49 @@ function buildQuery(CDS, args, resAnno, propKeys) {
609
646
  let qy = SELECT.from(resAnno.target).limit(limitTop, limitSkip);
610
647
  if ((propKeys?.length ?? 0) === 0)
611
648
  return qy;
612
- if (args.select?.length) {
649
+ // Handle expand - must be processed before select to build proper columns
650
+ if (args.expand) {
651
+ // Detect available associations (raw names, NOT _ID suffixed)
652
+ const assocNames = Array.from(resAnno.properties.entries())
653
+ .filter(([, cdsType]) => String(cdsType).toLowerCase().includes("association"))
654
+ .map(([name]) => name);
655
+ // Normalize expand to array (handle both string and array input)
656
+ const expandInput = Array.isArray(args.expand)
657
+ ? args.expand
658
+ : [args.expand];
659
+ // Determine which associations to expand
660
+ const expandList = expandInput.includes("*") || expandInput[0] === "*"
661
+ ? assocNames
662
+ : expandInput.filter((e) => assocNames.includes(e));
663
+ // Build columns array with expand structures
664
+ if (expandList.length > 0) {
665
+ const expandColumns = expandList.map((name) => {
666
+ // Use pre-computed safe columns, or '*' if no omitted fields
667
+ const safeColumns = resAnno.getAssociationSafeColumns(name) ?? ["*"];
668
+ return {
669
+ ref: [name],
670
+ expand: safeColumns,
671
+ };
672
+ });
673
+ // Use safe columns for main entity too
674
+ const mainColumns = resAnno.safeColumns;
675
+ if (args.select?.length) {
676
+ // Filter user's select to only safe columns
677
+ const safeSelect = args.select.filter((field) => !resAnno.omittedFields?.has(field));
678
+ qy = qy.columns(...safeSelect, ...expandColumns);
679
+ }
680
+ else if (mainColumns[0] === "*") {
681
+ qy = qy.columns("*", ...expandColumns);
682
+ }
683
+ else {
684
+ qy = qy.columns(...mainColumns, ...expandColumns);
685
+ }
686
+ }
687
+ else if (args.select?.length) {
688
+ qy = qy.columns(...args.select);
689
+ }
690
+ }
691
+ else if (args.select?.length) {
613
692
  qy = qy.columns(...args.select);
614
693
  }
615
694
  if (args.orderby?.length) {
@@ -82,6 +82,23 @@ function assignResourceToServer(model, server, authEnabled) {
82
82
  const validatedOrderBy = validator.validateOrderBy(v);
83
83
  query.orderBy(validatedOrderBy);
84
84
  continue;
85
+ case "expand":
86
+ // Handle expand for associations
87
+ const validatedExpand = validator.validateExpand(v);
88
+ // Replace '*' with safe columns for each association
89
+ const safeExpandColumns = validatedExpand.map((assoc) => {
90
+ const assocName = assoc.ref?.[0];
91
+ const safeCols = model.getAssociationSafeColumns?.(assocName) ?? ["*"];
92
+ return {
93
+ ref: assoc.ref,
94
+ expand: safeCols,
95
+ };
96
+ });
97
+ // Use main entity's safe columns
98
+ const mainSafe = model.safeColumns ?? ["*"];
99
+ const cols = query.SELECT?.columns || mainSafe;
100
+ query.columns(...cols, ...safeExpandColumns);
101
+ continue;
85
102
  default:
86
103
  continue;
87
104
  }
package/lib/mcp/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.determineMcpParameterType = determineMcpParameterType;
4
+ exports.buildDeepInsertZodType = buildDeepInsertZodType;
4
5
  exports.handleMcpSessionRequest = handleMcpSessionRequest;
5
6
  exports.writeODataDescriptionForResource = writeODataDescriptionForResource;
6
7
  exports.toolError = toolError;
@@ -22,13 +23,13 @@ function determineMcpParameterType(cdsType, key, target) {
22
23
  case "UUID":
23
24
  return zod_1.z.string();
24
25
  case "Date":
25
- return zod_1.z.date();
26
+ return zod_1.z.coerce.date();
26
27
  case "Time":
27
- return zod_1.z.date();
28
+ return zod_1.z.coerce.date();
28
29
  case "DateTime":
29
- return zod_1.z.date();
30
+ return zod_1.z.coerce.date();
30
31
  case "Timestamp":
31
- return zod_1.z.number();
32
+ return zod_1.z.coerce.date();
32
33
  case "Integer":
33
34
  return zod_1.z.number();
34
35
  case "Int16":
@@ -56,13 +57,13 @@ function determineMcpParameterType(cdsType, key, target) {
56
57
  case "StringArray":
57
58
  return zod_1.z.array(zod_1.z.string());
58
59
  case "DateArray":
59
- return zod_1.z.array(zod_1.z.date());
60
+ return zod_1.z.array(zod_1.z.coerce.date());
60
61
  case "TimeArray":
61
- return zod_1.z.array(zod_1.z.date());
62
+ return zod_1.z.array(zod_1.z.coerce.date());
62
63
  case "DateTimeArray":
63
- return zod_1.z.array(zod_1.z.date());
64
+ return zod_1.z.array(zod_1.z.coerce.date());
64
65
  case "TimestampArray":
65
- return zod_1.z.array(zod_1.z.number());
66
+ return zod_1.z.array(zod_1.z.coerce.date());
66
67
  case "UUIDArray":
67
68
  return zod_1.z.array(zod_1.z.string());
68
69
  case "IntegerArray":
@@ -148,6 +149,43 @@ function buildCompositionZodType(key, target) {
148
149
  const zodType = zod_1.z.object(Object.fromEntries(compProperties));
149
150
  return isArray ? zod_1.z.array(zodType) : zodType;
150
151
  }
152
+ /**
153
+ * Builds a Zod schema for deep insert by referencing another entity's schema
154
+ * Similar to buildCompositionZodType but works with explicit entity references
155
+ * @param targetEntityName - Full entity name (e.g., 'OnPremiseBookingService.BookingItems')
156
+ * @returns ZodType array schema for the target entity
157
+ */
158
+ function buildDeepInsertZodType(targetEntityName) {
159
+ const model = cds.model;
160
+ if (!model.definitions || !targetEntityName) {
161
+ return zod_1.z.array(zod_1.z.object({})); // fallback
162
+ }
163
+ const targetDef = model.definitions[targetEntityName];
164
+ if (!targetDef || !targetDef.elements) {
165
+ return zod_1.z.array(zod_1.z.object({}));
166
+ }
167
+ const itemProperties = new Map();
168
+ for (const [k, v] of Object.entries(targetDef.elements)) {
169
+ if (!v.type)
170
+ continue;
171
+ const elementKeys = new Map(Object.keys(v).map((el) => [el.toLowerCase(), el]));
172
+ // Skip computed fields
173
+ const isComputed = elementKeys.has("@core.computed") &&
174
+ v[elementKeys.get("@core.computed") ?? ""] === true;
175
+ if (isComputed)
176
+ continue;
177
+ const parsedType = v.type.replace("cds.", "");
178
+ // Skip associations and compositions in deep insert items
179
+ if (parsedType === "Association" || parsedType === "Composition")
180
+ continue;
181
+ // Make all non-key fields optional (consistent with direct entity create)
182
+ // This ensures external services with notNull on all fields don't require all fields
183
+ const paramType = determineMcpParameterType(parsedType);
184
+ itemProperties.set(k, v.key ? paramType : paramType.optional());
185
+ }
186
+ const zodType = zod_1.z.object(Object.fromEntries(itemProperties));
187
+ return zod_1.z.array(zodType); // Always return array for deep insert
188
+ }
151
189
  /**
152
190
  * Handles incoming MCP session requests by validating session IDs and routing to appropriate session
153
191
  * @param req - Express request object containing session headers
@@ -191,6 +229,9 @@ function writeODataDescriptionForResource(model) {
191
229
  if (model.functionalities.has("orderby")) {
192
230
  description += `- orderby: OData $orderby syntax (e.g., "$orderby=property1 asc", or "$orderby=property1 desc")${constants_1.NEW_LINE}`;
193
231
  }
232
+ if (model.functionalities.has("expand")) {
233
+ description += `- expand: OData $expand syntax to include related associations (e.g., $expand=* for all, or $expand=property1,property2 for specific ones)${constants_1.NEW_LINE}`;
234
+ }
194
235
  description += `${constants_1.NEW_LINE}Available properties on ${model.target}: ${constants_1.NEW_LINE}`;
195
236
  for (const [key, type] of model.properties.entries()) {
196
237
  description += `- ${key} -> value type = ${type} ${constants_1.NEW_LINE}`;
@@ -146,6 +146,42 @@ class ODataQueryValidator {
146
146
  }
147
147
  return validated;
148
148
  }
149
+ /**
150
+ * Validates and sanitizes the $expand query parameter
151
+ * @param value - Expand parameter: '*' for all associations, or comma-separated list
152
+ * @returns Array of CQN column objects with expand configuration
153
+ * @throws Error if any association name is invalid or not allowed
154
+ */
155
+ validateExpand(value) {
156
+ const decoded = decodeURIComponent(value).trim();
157
+ // Get available associations from the entity
158
+ const availableAssociations = Array.from(this.allowedTypes.entries())
159
+ .filter(([, cdsType]) => String(cdsType).toLowerCase().includes("association"))
160
+ .map(([name]) => name);
161
+ if (availableAssociations.length === 0) {
162
+ throw new Error("No associations available for expansion");
163
+ }
164
+ // Parse expand parameter: '*' for all, or comma-separated list
165
+ const expandList = decoded === "*"
166
+ ? availableAssociations
167
+ : decoded
168
+ .split(",")
169
+ .map((e) => e.trim())
170
+ .filter((e) => {
171
+ if (!availableAssociations.includes(e)) {
172
+ throw new Error(`Invalid expand association: ${e}. Available associations: ${availableAssociations.join(", ")}`);
173
+ }
174
+ return true;
175
+ });
176
+ if (expandList.length === 0) {
177
+ throw new Error("No valid associations specified for expansion");
178
+ }
179
+ // Return CQN column objects for expansion
180
+ return expandList.map((name) => ({
181
+ ref: [name],
182
+ expand: ["*"],
183
+ }));
184
+ }
149
185
  /**
150
186
  * Validates and sanitizes the $filter query parameter with comprehensive security checks
151
187
  * Prevents injection attacks, validates operators and property references
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavdi/cap-mcp",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "description": "MCP Plugin for CAP",
5
5
  "keywords": [
6
6
  "MCP",