@gavdi/cap-mcp 1.1.0 → 1.1.2
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 +3 -0
- package/cds-plugin.js +2 -2
- package/lib/annotations/parser.js +7 -4
- package/lib/annotations/structures.js +13 -1
- package/lib/annotations/utils.js +23 -7
- package/lib/mcp/describe-model.js +7 -5
- package/lib/mcp/entity-tools.js +22 -41
- package/lib/mcp/utils.js +39 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
# CAP MCP Plugin - AI With Ease
|
|
2
|
+
  
|
|
3
|
+
|
|
4
|
+
|
|
2
5
|
|
|
3
6
|
> This implementation is based on the Model Context Protocol (MCP) put forward by Anthropic.
|
|
4
7
|
> For more information on MCP, please have a look at their [official documentation.](https://modelcontextprotocol.io/introduction)
|
package/cds-plugin.js
CHANGED
|
@@ -12,8 +12,8 @@ cds.on("bootstrap", async (app) => {
|
|
|
12
12
|
await plugin?.onBootstrap(app);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
cds.on("
|
|
16
|
-
await plugin?.onLoaded(model);
|
|
15
|
+
cds.on("serving", async () => {
|
|
16
|
+
await plugin?.onLoaded(cds.model);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
cds.on("shutdown", async () => {
|
|
@@ -36,7 +36,7 @@ function parseDefinitions(model) {
|
|
|
36
36
|
const verifiedAnnotations = parsedAnnotations;
|
|
37
37
|
switch (def.kind) {
|
|
38
38
|
case "entity":
|
|
39
|
-
const resourceAnnotation = constructResourceAnnotation(serviceName, target, verifiedAnnotations, def);
|
|
39
|
+
const resourceAnnotation = constructResourceAnnotation(serviceName, target, verifiedAnnotations, def, model);
|
|
40
40
|
if (!resourceAnnotation)
|
|
41
41
|
continue;
|
|
42
42
|
result.set(resourceAnnotation.target, resourceAnnotation);
|
|
@@ -122,13 +122,16 @@ function parseAnnotations(definition) {
|
|
|
122
122
|
* @param definition - CSN definition object
|
|
123
123
|
* @returns Resource annotation or undefined if invalid
|
|
124
124
|
*/
|
|
125
|
-
function constructResourceAnnotation(serviceName, target, annotations, definition) {
|
|
125
|
+
function constructResourceAnnotation(serviceName, target, annotations, definition, model) {
|
|
126
126
|
if (!(0, utils_1.isValidResourceAnnotation)(annotations))
|
|
127
127
|
return undefined;
|
|
128
128
|
const functionalities = (0, utils_1.determineResourceOptions)(annotations);
|
|
129
|
-
const
|
|
129
|
+
const foreignKeys = new Map(Object.entries(model.definitions?.[`${serviceName}.${target}`].elements ?? {})
|
|
130
|
+
.filter(([_, v]) => v["@odata.foreignKey4"] !== undefined)
|
|
131
|
+
.map(([k, v]) => [k, v["@odata.foreignKey4"]]));
|
|
132
|
+
const { properties, resourceKeys } = (0, utils_1.parseResourceElements)(definition, model);
|
|
130
133
|
const restrictions = (0, utils_1.parseCdsRestrictions)(annotations.restrict, annotations.requires);
|
|
131
|
-
return new structures_1.McpResourceAnnotation(annotations.name, annotations.description, target, serviceName, functionalities, properties, resourceKeys, annotations.wrap, restrictions);
|
|
134
|
+
return new structures_1.McpResourceAnnotation(annotations.name, annotations.description, target, serviceName, functionalities, properties, resourceKeys, foreignKeys, annotations.wrap, restrictions);
|
|
132
135
|
}
|
|
133
136
|
/**
|
|
134
137
|
* Constructs a tool annotation from parsed annotation data
|
|
@@ -82,6 +82,8 @@ class McpResourceAnnotation extends McpAnnotation {
|
|
|
82
82
|
_resourceKeys;
|
|
83
83
|
/** Optional wrapper configuration to expose this resource as tools */
|
|
84
84
|
_wrap;
|
|
85
|
+
/** Map of foreign keys property -> associated entity */
|
|
86
|
+
_foreignKeys;
|
|
85
87
|
/**
|
|
86
88
|
* Creates a new MCP resource annotation
|
|
87
89
|
* @param name - Unique identifier for this resource
|
|
@@ -91,14 +93,17 @@ class McpResourceAnnotation extends McpAnnotation {
|
|
|
91
93
|
* @param functionalities - Set of enabled OData query options (filter, top, skip, etc.)
|
|
92
94
|
* @param properties - Map of entity properties to their CDS types
|
|
93
95
|
* @param resourceKeys - Map of key fields to their types
|
|
96
|
+
* @param foreignKeys - Map of foreign keys used by entity
|
|
97
|
+
* @param wrap - Wrap usage
|
|
94
98
|
* @param restrictions - Optional restrictions based on CDS roles
|
|
95
99
|
*/
|
|
96
|
-
constructor(name, description, target, serviceName, functionalities, properties, resourceKeys, wrap, restrictions) {
|
|
100
|
+
constructor(name, description, target, serviceName, functionalities, properties, resourceKeys, foreignKeys, wrap, restrictions) {
|
|
97
101
|
super(name, description, target, serviceName, restrictions ?? []);
|
|
98
102
|
this._functionalities = functionalities;
|
|
99
103
|
this._properties = properties;
|
|
100
104
|
this._resourceKeys = resourceKeys;
|
|
101
105
|
this._wrap = wrap;
|
|
106
|
+
this._foreignKeys = foreignKeys;
|
|
102
107
|
}
|
|
103
108
|
/**
|
|
104
109
|
* Gets the set of enabled OData query functionalities
|
|
@@ -107,6 +112,13 @@ class McpResourceAnnotation extends McpAnnotation {
|
|
|
107
112
|
get functionalities() {
|
|
108
113
|
return this._functionalities;
|
|
109
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Gets the map of foreign keys used withing the resource
|
|
117
|
+
* @returns Map of foreign keys - property name -> associated entity
|
|
118
|
+
*/
|
|
119
|
+
get foreignKeys() {
|
|
120
|
+
return this._foreignKeys;
|
|
121
|
+
}
|
|
110
122
|
/**
|
|
111
123
|
* Gets the map of entity properties to their CDS types
|
|
112
124
|
* @returns Map of property names to type strings
|
package/lib/annotations/utils.js
CHANGED
|
@@ -153,17 +153,33 @@ function determineResourceOptions(annotations) {
|
|
|
153
153
|
* @param definition - The definition to parse
|
|
154
154
|
* @returns Object containing properties and resource keys maps
|
|
155
155
|
*/
|
|
156
|
-
function parseResourceElements(definition) {
|
|
156
|
+
function parseResourceElements(definition, model) {
|
|
157
157
|
const properties = new Map();
|
|
158
158
|
const resourceKeys = new Map();
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
const parseParam = (k, v, suffix) => {
|
|
160
|
+
let result = "";
|
|
161
|
+
if (typeof v.type !== "string") {
|
|
162
|
+
const referencedType = parseTypedReference(v.type, model);
|
|
163
|
+
result = `${referencedType}${suffix ?? ""}`;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
result = `${v.type.replace("cds.", "")}${suffix ?? ""}`;
|
|
167
|
+
}
|
|
168
|
+
properties?.set(k, result);
|
|
169
|
+
return result;
|
|
170
|
+
};
|
|
171
|
+
for (const [k, v] of Object.entries(definition.elements || {})) {
|
|
172
|
+
if (v.items) {
|
|
173
|
+
const result = parseParam(k, v.items, "Array");
|
|
174
|
+
if (!v.key)
|
|
175
|
+
continue;
|
|
176
|
+
resourceKeys.set(k, result);
|
|
161
177
|
continue;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (!
|
|
178
|
+
}
|
|
179
|
+
const result = parseParam(k, v);
|
|
180
|
+
if (!v.key || v.type === "cds.Association")
|
|
165
181
|
continue;
|
|
166
|
-
resourceKeys.set(
|
|
182
|
+
resourceKeys.set(k, result);
|
|
167
183
|
}
|
|
168
184
|
return {
|
|
169
185
|
properties,
|
|
@@ -30,24 +30,26 @@ function registerDescribeModelTool(server) {
|
|
|
30
30
|
const refl = CDS.reflect(CDS.model);
|
|
31
31
|
const listServices = () => {
|
|
32
32
|
const names = Object.values(CDS.services || {})
|
|
33
|
-
.map((s) => s?.definition?.name || s?.name)
|
|
33
|
+
.map((s) => s?.namespace || s?.definition?.name || s?.name)
|
|
34
34
|
.filter(Boolean);
|
|
35
35
|
return { services: [...new Set(names)].sort() };
|
|
36
36
|
};
|
|
37
37
|
const listEntities = (service) => {
|
|
38
|
-
const all = Object.
|
|
38
|
+
const all = Object.entries(refl.definitions || {})
|
|
39
|
+
.filter((x) => x[1].kind == "entity" && !x[0].startsWith("cds.")) // ignore entities such as "cds.outbox.Messages"
|
|
40
|
+
.map((x) => x[0]);
|
|
39
41
|
const filtered = service
|
|
40
|
-
? all.filter((e) =>
|
|
42
|
+
? all.filter((e) => e.startsWith(service + "."))
|
|
41
43
|
: all;
|
|
42
44
|
return {
|
|
43
|
-
entities: filtered.
|
|
45
|
+
entities: filtered.sort(),
|
|
44
46
|
};
|
|
45
47
|
};
|
|
46
48
|
const describeEntity = (service, entity) => {
|
|
47
49
|
if (!entity)
|
|
48
50
|
return { error: "Please provide 'entity'." };
|
|
49
51
|
const fqn = service && !entity.includes(".") ? `${service}.${entity}` : entity;
|
|
50
|
-
const ent = (refl.
|
|
52
|
+
const ent = (refl.definitions || {})[fqn] || (refl.definitions || {})[entity];
|
|
51
53
|
if (!ent)
|
|
52
54
|
return {
|
|
53
55
|
error: `Entity not found: ${entity}${service ? ` (service ${service})` : ""}`,
|
package/lib/mcp/entity-tools.js
CHANGED
|
@@ -83,23 +83,6 @@ function buildEnhancedQueryDescription(resAnno) {
|
|
|
83
83
|
: "";
|
|
84
84
|
return baseDesc + assocHint;
|
|
85
85
|
}
|
|
86
|
-
/**
|
|
87
|
-
* Builds field documentation for schema descriptions
|
|
88
|
-
*/
|
|
89
|
-
function buildFieldDocumentation(resAnno) {
|
|
90
|
-
const docs = [];
|
|
91
|
-
for (const [propName, cdsType] of resAnno.properties.entries()) {
|
|
92
|
-
const isAssociation = String(cdsType).toLowerCase().includes("association");
|
|
93
|
-
if (isAssociation) {
|
|
94
|
-
docs.push(`${propName}(association: compare by key value)`);
|
|
95
|
-
docs.push(`${propName}_ID(foreign key for ${propName})`);
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
docs.push(`${propName}(${String(cdsType).toLowerCase()})`);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return docs.join(", ");
|
|
102
|
-
}
|
|
103
86
|
/**
|
|
104
87
|
* Registers CRUD-like MCP tools for an annotated entity (resource).
|
|
105
88
|
* Modes can be controlled globally via configuration and per-entity via @mcp.wrap.
|
|
@@ -158,13 +141,6 @@ function registerQueryTool(resAnno, server, authEnabled) {
|
|
|
158
141
|
const scalarKeys = Array.from(resAnno.properties.entries())
|
|
159
142
|
.filter(([, cdsType]) => !String(cdsType).toLowerCase().includes("association"))
|
|
160
143
|
.map(([name]) => name);
|
|
161
|
-
// Add foreign key fields for associations to scalar keys for select/orderby
|
|
162
|
-
for (const [propName, cdsType] of resAnno.properties.entries()) {
|
|
163
|
-
const isAssociation = String(cdsType).toLowerCase().includes("association");
|
|
164
|
-
if (isAssociation) {
|
|
165
|
-
scalarKeys.push(`${propName}_ID`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
144
|
// Build where field enum: use same fields as select (scalar + foreign keys)
|
|
169
145
|
// This ensures consistency - what you can select, you can filter by
|
|
170
146
|
const whereKeys = [...scalarKeys];
|
|
@@ -369,16 +345,14 @@ function registerCreateTool(resAnno, server, authEnabled) {
|
|
|
369
345
|
for (const [propName, cdsType] of resAnno.properties.entries()) {
|
|
370
346
|
const isAssociation = String(cdsType).toLowerCase().includes("association");
|
|
371
347
|
if (isAssociation) {
|
|
372
|
-
//
|
|
373
|
-
inputSchema[`${propName}_ID`] = zod_1.z
|
|
374
|
-
.number()
|
|
375
|
-
.describe(`Foreign key for association ${propName}`)
|
|
376
|
-
.optional();
|
|
348
|
+
// Association keys are supplied directly from model loading as of v1.1.2
|
|
377
349
|
continue;
|
|
378
350
|
}
|
|
379
|
-
inputSchema[propName] = (0, utils_2.determineMcpParameterType)(cdsType)
|
|
351
|
+
inputSchema[propName] = (0, utils_2.determineMcpParameterType)(cdsType, propName, `${resAnno.serviceName}.${resAnno.target}`)
|
|
380
352
|
.optional()
|
|
381
|
-
.describe(
|
|
353
|
+
.describe(resAnno.foreignKeys.has(propName)
|
|
354
|
+
? `Foreign key to ${resAnno.foreignKeys.get(propName)} on ${propName}`
|
|
355
|
+
: `Field ${propName}`);
|
|
382
356
|
}
|
|
383
357
|
const hint = constructHintMessage(resAnno, "create");
|
|
384
358
|
const desc = `Resource description: ${resAnno.description}. Create a new ${resAnno.target}. Provide fields; service applies defaults.${hint}`;
|
|
@@ -459,15 +433,14 @@ function registerUpdateTool(resAnno, server, authEnabled) {
|
|
|
459
433
|
continue;
|
|
460
434
|
const isAssociation = String(cdsType).toLowerCase().includes("association");
|
|
461
435
|
if (isAssociation) {
|
|
462
|
-
|
|
463
|
-
.number()
|
|
464
|
-
.describe(`Foreign key for association ${propName}`)
|
|
465
|
-
.optional();
|
|
436
|
+
// Association keys are supplied directly from model loading as of v1.1.2
|
|
466
437
|
continue;
|
|
467
438
|
}
|
|
468
|
-
inputSchema[propName] = (0, utils_2.determineMcpParameterType)(cdsType)
|
|
439
|
+
inputSchema[propName] = (0, utils_2.determineMcpParameterType)(cdsType, propName, `${resAnno.serviceName}.${resAnno.target}`)
|
|
469
440
|
.optional()
|
|
470
|
-
.describe(
|
|
441
|
+
.describe(resAnno.foreignKeys.has(propName)
|
|
442
|
+
? `Foreign key to ${resAnno.foreignKeys.get(propName)} on ${propName}`
|
|
443
|
+
: `Field ${propName}`);
|
|
471
444
|
}
|
|
472
445
|
const keyList = Array.from(resAnno.resourceKeys.keys()).join(", ");
|
|
473
446
|
const hint = constructHintMessage(resAnno, "update");
|
|
@@ -687,7 +660,11 @@ async function executeQuery(CDS, svc, args, baseQuery) {
|
|
|
687
660
|
const { SELECT } = CDS.ql;
|
|
688
661
|
switch (args.return) {
|
|
689
662
|
case "count": {
|
|
690
|
-
const countQuery = SELECT.from(baseQuery.SELECT.from)
|
|
663
|
+
const countQuery = SELECT.from(baseQuery.SELECT.from)
|
|
664
|
+
.columns("count(1) as count")
|
|
665
|
+
.where(baseQuery.SELECT.where)
|
|
666
|
+
.limit(baseQuery.SELECT.limit?.rows?.val, baseQuery.SELECT.limit?.offset?.val)
|
|
667
|
+
.orderBy(baseQuery.SELECT.orderBy);
|
|
691
668
|
const result = await svc.run(countQuery);
|
|
692
669
|
const row = Array.isArray(result) ? result[0] : result;
|
|
693
670
|
return { count: row?.count ?? 0 };
|
|
@@ -696,11 +673,15 @@ async function executeQuery(CDS, svc, args, baseQuery) {
|
|
|
696
673
|
if (!args.aggregate?.length)
|
|
697
674
|
return [];
|
|
698
675
|
const cols = args.aggregate.map((a) => `${a.fn}(${a.field}) as ${a.fn}_${a.field}`);
|
|
699
|
-
const aggQuery = SELECT.from(baseQuery.SELECT.from)
|
|
700
|
-
|
|
676
|
+
const aggQuery = SELECT.from(baseQuery.SELECT.from)
|
|
677
|
+
.columns(...cols)
|
|
678
|
+
.where(baseQuery.SELECT.where)
|
|
679
|
+
.limit(baseQuery.SELECT.limit?.rows?.val, baseQuery.SELECT.limit?.offset?.val)
|
|
680
|
+
.orderBy(baseQuery.SELECT.orderBy);
|
|
681
|
+
return await svc.run(aggQuery);
|
|
701
682
|
}
|
|
702
683
|
default:
|
|
703
|
-
return svc.run(baseQuery);
|
|
684
|
+
return await svc.run(baseQuery);
|
|
704
685
|
}
|
|
705
686
|
}
|
|
706
687
|
function constructHintMessage(resAnno, wrapAction) {
|
package/lib/mcp/utils.js
CHANGED
|
@@ -7,12 +7,14 @@ exports.toolError = toolError;
|
|
|
7
7
|
exports.asMcpResult = asMcpResult;
|
|
8
8
|
const constants_1 = require("./constants");
|
|
9
9
|
const zod_1 = require("zod");
|
|
10
|
+
/* @ts-ignore */
|
|
11
|
+
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
|
10
12
|
/**
|
|
11
13
|
* Converts a CDS type string to the corresponding Zod schema type
|
|
12
14
|
* @param cdsType - The CDS type name (e.g., 'String', 'Integer')
|
|
13
15
|
* @returns Zod schema instance for the given type
|
|
14
16
|
*/
|
|
15
|
-
function determineMcpParameterType(cdsType) {
|
|
17
|
+
function determineMcpParameterType(cdsType, key, target) {
|
|
16
18
|
switch (cdsType) {
|
|
17
19
|
case "String":
|
|
18
20
|
return zod_1.z.string();
|
|
@@ -49,7 +51,7 @@ function determineMcpParameterType(cdsType) {
|
|
|
49
51
|
case "LargeString":
|
|
50
52
|
return zod_1.z.string();
|
|
51
53
|
case "Map":
|
|
52
|
-
return zod_1.z.
|
|
54
|
+
return zod_1.z.object({});
|
|
53
55
|
case "StringArray":
|
|
54
56
|
return zod_1.z.array(zod_1.z.string());
|
|
55
57
|
case "DateArray":
|
|
@@ -85,11 +87,45 @@ function determineMcpParameterType(cdsType) {
|
|
|
85
87
|
case "LargeStringArray":
|
|
86
88
|
return zod_1.z.array(zod_1.z.string());
|
|
87
89
|
case "MapArray":
|
|
88
|
-
return zod_1.z.array(zod_1.z.
|
|
90
|
+
return zod_1.z.array(zod_1.z.object({}));
|
|
91
|
+
case "Composition":
|
|
92
|
+
return buildCompositionZodType(key, target);
|
|
89
93
|
default:
|
|
90
94
|
return zod_1.z.string();
|
|
91
95
|
}
|
|
92
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Builds the complex ZodType for a CDS type of 'Composition'
|
|
99
|
+
* @param key
|
|
100
|
+
* @param target
|
|
101
|
+
* @returns ZodType
|
|
102
|
+
*/
|
|
103
|
+
function buildCompositionZodType(key, target) {
|
|
104
|
+
const model = cds.model;
|
|
105
|
+
if (!model.definitions || !target || !key) {
|
|
106
|
+
return zod_1.z.object({}); // fallback, might have to reconsider type later
|
|
107
|
+
}
|
|
108
|
+
const targetDef = model.definitions[target];
|
|
109
|
+
const targetProp = targetDef.elements[key];
|
|
110
|
+
const comp = model.definitions[targetProp.target];
|
|
111
|
+
if (!comp) {
|
|
112
|
+
return zod_1.z.object({});
|
|
113
|
+
}
|
|
114
|
+
const isArray = targetProp.cardinality !== undefined;
|
|
115
|
+
const compProperties = new Map();
|
|
116
|
+
for (const [k, v] of Object.entries(comp.elements)) {
|
|
117
|
+
if (!v.type)
|
|
118
|
+
continue;
|
|
119
|
+
const parsedType = v.type.replace("cds.", "");
|
|
120
|
+
if (parsedType === "Association" || parsedType === "Composition")
|
|
121
|
+
continue; // We will not support nested compositions for now
|
|
122
|
+
const isOptional = !v.key && !v.notNull;
|
|
123
|
+
const paramType = determineMcpParameterType(parsedType);
|
|
124
|
+
compProperties.set(k, isOptional ? paramType.optional() : paramType);
|
|
125
|
+
}
|
|
126
|
+
const zodType = zod_1.z.object(Object.fromEntries(compProperties));
|
|
127
|
+
return isArray ? zod_1.z.array(zodType) : zodType;
|
|
128
|
+
}
|
|
93
129
|
/**
|
|
94
130
|
* Handles incoming MCP session requests by validating session IDs and routing to appropriate session
|
|
95
131
|
* @param req - Express request object containing session headers
|