@gavdi/cap-mcp 1.3.1 → 1.4.0

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
@@ -24,6 +24,8 @@ By integrating MCP with your CAP applications, you unlock:
24
24
 
25
25
  ## 🚀 Quick Setup
26
26
 
27
+ > Want to read the full documentation? [Find it here](https://gavdilabs.github.io/cap-mcp-plugin/#/)
28
+
27
29
  ### Prerequisites
28
30
 
29
31
  - **Node.js**: Version 18 or higher
@@ -68,7 +70,7 @@ service CatalogService {
68
70
  @mcp: {
69
71
  name: 'books',
70
72
  description: 'Book catalog with search and filtering',
71
- resource: ['filter', 'orderby', 'select', 'top', 'skip']
73
+ resource: ['filter', 'orderby', 'select', 'top', 'skip', 'expand']
72
74
  }
73
75
  entity Books as projection on my.Books;
74
76
 
@@ -115,6 +117,7 @@ This plugin transforms your annotated CAP services into a fully functional MCP s
115
117
  - **📊 Resources**: Expose CAP entities as MCP resources with OData v4 query capabilities
116
118
  - **🔧 Tools**: Convert CAP functions and actions into executable MCP tools
117
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
118
121
  - **💡 Prompts**: Define reusable prompt templates for AI interactions
119
122
  - **⚡ Elicitation**: Request user confirmation or input parameters before tool execution
120
123
  - **🔄 Auto-generation**: Automatically creates MCP server endpoints based on annotations
@@ -148,7 +151,8 @@ service CatalogService {
148
151
  'orderby',
149
152
  'select',
150
153
  'skip',
151
- 'top'
154
+ 'top',
155
+ 'expand'
152
156
  ]
153
157
  }
154
158
  entity Books as projection on my.Books;
@@ -172,7 +176,7 @@ service CatalogService {
172
176
  ```
173
177
 
174
178
  **Generated MCP Resource Capabilities:**
175
- - **OData v4 Query Support**: `$filter`, `$orderby`, `$top`, `$skip`, `$select`
179
+ - **OData v4 Query Support**: `$filter`, `$orderby`, `$top`, `$skip`, `$select`, `$expand`
176
180
  - **Natural Language Queries**: "Find books by Stephen King with stock > 20"
177
181
  - **Dynamic Filtering**: Complex filter expressions using OData syntax
178
182
  - **Flexible Selection**: Choose specific fields and sort orders
package/cds-plugin.js CHANGED
@@ -5,7 +5,7 @@ const McpBuild = require("./lib/config/build");
5
5
  // Build tasks
6
6
  McpBuild.registerBuildTask();
7
7
 
8
- const plugin = new McpPlugin();
8
+ const plugin = McpPlugin.getInstance();
9
9
 
10
10
  // Plugin hooks event registration
11
11
  cds.on("bootstrap", async (app) => {
@@ -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,6 +393,15 @@ 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
407
  const val = args[fkName];
@@ -438,8 +463,19 @@ function registerUpdateTool(resAnno, server, authEnabled) {
438
463
  continue;
439
464
  const isComputed = resAnno.computedFields?.has(propName);
440
465
  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
466
+ if (isComputed) {
467
+ continue;
468
+ }
469
+ // Check if this association is marked for deep insert
470
+ if (isAssociation) {
471
+ if (resAnno.deepInsertRefs.has(propName)) {
472
+ // This association has @mcp.deepInsert annotation
473
+ const targetEntityName = resAnno.deepInsertRefs.get(propName);
474
+ inputSchema[propName] = (0, utils_2.buildDeepInsertZodType)(targetEntityName)
475
+ .optional()
476
+ .describe(`Deep update array for ${propName}. ${resAnno.propertyHints.get(propName) ?? ""}`);
477
+ }
478
+ // Skip regular associations (no deep insert)
443
479
  continue;
444
480
  }
445
481
  inputSchema[propName] = (0, utils_2.determineMcpParameterType)(cdsType, propName, `${resAnno.serviceName}.${resAnno.target}`)
@@ -480,6 +516,15 @@ function registerUpdateTool(resAnno, server, authEnabled) {
480
516
  .toLowerCase()
481
517
  .includes("association");
482
518
  if (isAssociation) {
519
+ // Check if this association is marked for deep insert
520
+ if (resAnno.deepInsertRefs.has(propName)) {
521
+ // Pass through the nested array for deep update
522
+ if (args[propName] !== undefined && Array.isArray(args[propName])) {
523
+ updates[propName] = args[propName];
524
+ }
525
+ continue;
526
+ }
527
+ // Regular association - use foreign key
483
528
  const fkName = `${propName}_ID`;
484
529
  if (args[fkName] !== undefined) {
485
530
  const val = args[fkName];
@@ -609,7 +654,49 @@ function buildQuery(CDS, args, resAnno, propKeys) {
609
654
  let qy = SELECT.from(resAnno.target).limit(limitTop, limitSkip);
610
655
  if ((propKeys?.length ?? 0) === 0)
611
656
  return qy;
612
- if (args.select?.length) {
657
+ // Handle expand - must be processed before select to build proper columns
658
+ if (args.expand) {
659
+ // Detect available associations (raw names, NOT _ID suffixed)
660
+ const assocNames = Array.from(resAnno.properties.entries())
661
+ .filter(([, cdsType]) => String(cdsType).toLowerCase().includes("association"))
662
+ .map(([name]) => name);
663
+ // Normalize expand to array (handle both string and array input)
664
+ const expandInput = Array.isArray(args.expand)
665
+ ? args.expand
666
+ : [args.expand];
667
+ // Determine which associations to expand
668
+ const expandList = expandInput.includes("*") || expandInput[0] === "*"
669
+ ? assocNames
670
+ : expandInput.filter((e) => assocNames.includes(e));
671
+ // Build columns array with expand structures
672
+ if (expandList.length > 0) {
673
+ const expandColumns = expandList.map((name) => {
674
+ // Use pre-computed safe columns, or '*' if no omitted fields
675
+ const safeColumns = resAnno.getAssociationSafeColumns(name) ?? ["*"];
676
+ return {
677
+ ref: [name],
678
+ expand: safeColumns,
679
+ };
680
+ });
681
+ // Use safe columns for main entity too
682
+ const mainColumns = resAnno.safeColumns;
683
+ if (args.select?.length) {
684
+ // Filter user's select to only safe columns
685
+ const safeSelect = args.select.filter((field) => !resAnno.omittedFields?.has(field));
686
+ qy = qy.columns(...safeSelect, ...expandColumns);
687
+ }
688
+ else if (mainColumns[0] === "*") {
689
+ qy = qy.columns("*", ...expandColumns);
690
+ }
691
+ else {
692
+ qy = qy.columns(...mainColumns, ...expandColumns);
693
+ }
694
+ }
695
+ else if (args.select?.length) {
696
+ qy = qy.columns(...args.select);
697
+ }
698
+ }
699
+ else if (args.select?.length) {
613
700
  qy = qy.columns(...args.select);
614
701
  }
615
702
  if (args.orderby?.length) {
@@ -24,8 +24,10 @@ function createMcpServer(config, annotations) {
24
24
  const server = new mcp_js_1.McpServer({
25
25
  name: config.name,
26
26
  version: config.version,
27
+ }, {
28
+ instructions: (0, instructions_1.getMcpInstructions)(config),
27
29
  capabilities: config.capabilities,
28
- }, { instructions: (0, instructions_1.getMcpInstructions)(config) });
30
+ });
29
31
  if (!annotations) {
30
32
  logger_1.LOGGER.debug("No annotations provided, skipping registration...");
31
33
  return server;
@@ -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/lib/mcp.js CHANGED
@@ -25,6 +25,8 @@ class McpPlugin {
25
25
  config;
26
26
  expressApp;
27
27
  annotations;
28
+ static _instance;
29
+ static isInitializing = false;
28
30
  /**
29
31
  * Creates a new MCP plugin instance with configuration and session management
30
32
  */
@@ -168,5 +170,38 @@ class McpPlugin {
168
170
  }
169
171
  });
170
172
  }
173
+ /**
174
+ * Get McpPlugin Instance
175
+ *
176
+ * @description Double Lock singleton initialization.
177
+ * @returns McpPlugin
178
+ */
179
+ static getInstance() {
180
+ if (!McpPlugin._instance) {
181
+ if (!McpPlugin.isInitializing) {
182
+ McpPlugin.isInitializing = true;
183
+ if (!McpPlugin._instance) {
184
+ McpPlugin._instance = new McpPlugin();
185
+ }
186
+ McpPlugin.isInitializing = false;
187
+ }
188
+ else {
189
+ /**
190
+ * Busy Wait if it is initializing
191
+ */
192
+ while (McpPlugin.isInitializing) { }
193
+ /**
194
+ * check if not init, call again.
195
+ */
196
+ if (!McpPlugin._instance) {
197
+ return McpPlugin.getInstance();
198
+ }
199
+ }
200
+ }
201
+ return McpPlugin._instance;
202
+ }
203
+ static resetInstance() {
204
+ McpPlugin._instance = undefined;
205
+ }
171
206
  }
172
207
  exports.default = McpPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavdi/cap-mcp",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "MCP Plugin for CAP",
5
5
  "keywords": [
6
6
  "MCP",
@@ -38,14 +38,16 @@
38
38
  "format": "prettier --check ./src",
39
39
  "format:fix": "prettier --write ./src \"**/*.json\"",
40
40
  "build:watch": "tsc --watch",
41
- "release": "release-it"
41
+ "release": "release-it",
42
+ "docs:serve": "docsify serve docs",
43
+ "docs:links": "markdown-link-check docs/**/*.md"
42
44
  },
43
45
  "peerDependencies": {
44
- "@sap/cds": "^9",
46
+ "@sap/cds": ">=9",
45
47
  "express": "^4"
46
48
  },
47
49
  "dependencies": {
48
- "@modelcontextprotocol/sdk": "^1.19.1",
50
+ "@modelcontextprotocol/sdk": "^1.23.0",
49
51
  "@sap/xssec": "^4.9.1",
50
52
  "cors": "^2.8.5",
51
53
  "helmet": "^8.1.0",
@@ -61,10 +63,12 @@
61
63
  "@types/node": "^24.0.3",
62
64
  "@types/sinon": "^17.0.4",
63
65
  "@types/supertest": "^6.0.2",
66
+ "docsify-cli": "^4.4.4",
64
67
  "eslint": "^9.29.0",
65
68
  "husky": "^9.1.7",
66
69
  "jest": "^29.7.0",
67
70
  "lint-staged": "^16.1.2",
71
+ "markdown-link-check": "^3.14.1",
68
72
  "prettier": "^3.5.3",
69
73
  "release-it": "^19.0.4",
70
74
  "sinon": "^21.0.0",