@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 +7 -3
- package/cds-plugin.js +1 -1
- package/lib/annotations/constants.js +7 -1
- package/lib/annotations/parser.js +29 -1
- package/lib/annotations/structures.js +34 -1
- package/lib/annotations/utils.js +27 -0
- package/lib/mcp/entity-tools.js +92 -5
- package/lib/mcp/factory.js +3 -1
- package/lib/mcp/resources.js +17 -0
- package/lib/mcp/utils.js +49 -8
- package/lib/mcp/validation.js +36 -0
- package/lib/mcp.js +35 -0
- package/package.json +8 -4
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
|
@@ -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
|
-
|
|
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
|
/**
|
package/lib/annotations/utils.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/mcp/entity-tools.js
CHANGED
|
@@ -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
|
|
352
|
-
|
|
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 (
|
|
442
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/mcp/factory.js
CHANGED
|
@@ -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
|
-
}
|
|
30
|
+
});
|
|
29
31
|
if (!annotations) {
|
|
30
32
|
logger_1.LOGGER.debug("No annotations provided, skipping registration...");
|
|
31
33
|
return server;
|
package/lib/mcp/resources.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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}`;
|
package/lib/mcp/validation.js
CHANGED
|
@@ -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
|
+
"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": "
|
|
46
|
+
"@sap/cds": ">=9",
|
|
45
47
|
"express": "^4"
|
|
46
48
|
},
|
|
47
49
|
"dependencies": {
|
|
48
|
-
"@modelcontextprotocol/sdk": "^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",
|