@gavdi/cap-mcp 0.9.7 → 0.9.8

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
@@ -62,7 +62,8 @@ Add MCP configuration to your `package.json`:
62
62
  "name": "my-bookshop-mcp",
63
63
  "auth": "inherit",
64
64
  "wrap_entities_to_actions": false,
65
- "wrap_entity_modes": ["query", "get"]
65
+ "wrap_entity_modes": ["query", "get"],
66
+ "instructions": "MCP server instructions for agents"
66
67
  }
67
68
  }
68
69
  }
@@ -251,6 +252,7 @@ Configure the MCP plugin through your CAP application's `package.json` or `.cdsr
251
252
  "name": "my-mcp-server",
252
253
  "version": "1.0.0",
253
254
  "auth": "inherit",
255
+ "instructions": "mcp server instructions for agents",
254
256
  "capabilities": {
255
257
  "resources": {
256
258
  "listChanged": true,
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_ALL_RESOURCE_OPTIONS = exports.MCP_ANNOTATION_PROPS = exports.MCP_ANNOTATION_KEY = void 0;
3
+ exports.DEFAULT_ALL_RESOURCE_OPTIONS = exports.CDS_AUTH_ANNOTATIONS = exports.MCP_ANNOTATION_PROPS = 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
@@ -28,6 +28,14 @@ exports.MCP_ANNOTATION_PROPS = {
28
28
  /** Wrapper configuration for exposing entities as tools */
29
29
  MCP_WRAP: "@mcp.wrap",
30
30
  };
31
+ /**
32
+ * Set of annotations used for CDS auth annotations
33
+ * Maps logical names to their actual annotation keys used in CDS files.
34
+ */
35
+ exports.CDS_AUTH_ANNOTATIONS = {
36
+ REQUIRES: "@requires",
37
+ RESTRICT: "@restrict",
38
+ };
31
39
  /**
32
40
  * Default set of all available OData query options for MCP resources
33
41
  * Used when @mcp.resource is set to `true` to enable all capabilities
@@ -70,8 +70,12 @@ function parseAnnotations(definition) {
70
70
  definition: definition,
71
71
  };
72
72
  for (const [k, v] of Object.entries(definition)) {
73
- if (!k.includes(constants_1.MCP_ANNOTATION_KEY))
73
+ // Process MCP annotations and CDS auth annotations
74
+ if (!k.includes(constants_1.MCP_ANNOTATION_KEY) &&
75
+ !k.startsWith("@requires") &&
76
+ !k.startsWith("@restrict")) {
74
77
  continue;
78
+ }
75
79
  logger_1.LOGGER.debug("Parsing: ", k, v);
76
80
  switch (k) {
77
81
  case constants_1.MCP_ANNOTATION_PROPS.MCP_NAME:
@@ -93,6 +97,12 @@ function parseAnnotations(definition) {
93
97
  // Wrapper container to expose resources as tools
94
98
  annotations.wrap = v;
95
99
  continue;
100
+ case constants_1.CDS_AUTH_ANNOTATIONS.REQUIRES:
101
+ annotations.requires = v;
102
+ continue;
103
+ case constants_1.CDS_AUTH_ANNOTATIONS.RESTRICT:
104
+ annotations.restrict = v;
105
+ continue;
96
106
  default:
97
107
  continue;
98
108
  }
@@ -112,7 +122,8 @@ function constructResourceAnnotation(serviceName, target, annotations, definitio
112
122
  return undefined;
113
123
  const functionalities = (0, utils_1.determineResourceOptions)(annotations);
114
124
  const { properties, resourceKeys } = (0, utils_1.parseResourceElements)(definition);
115
- return new structures_1.McpResourceAnnotation(annotations.name, annotations.description, target, serviceName, functionalities, properties, resourceKeys, annotations.wrap);
125
+ const restrictions = (0, utils_1.parseCdsRestrictions)(annotations.restrict, annotations.requires);
126
+ return new structures_1.McpResourceAnnotation(annotations.name, annotations.description, target, serviceName, functionalities, properties, resourceKeys, annotations.wrap, restrictions);
116
127
  }
117
128
  /**
118
129
  * Constructs a tool annotation from parsed annotation data
@@ -127,7 +138,8 @@ function constructToolAnnotation(serviceName, target, annotations, entityKey, ke
127
138
  if (!(0, utils_1.isValidToolAnnotation)(annotations))
128
139
  return undefined;
129
140
  const { parameters, operationKind } = (0, utils_1.parseOperationElements)(annotations);
130
- return new structures_1.McpToolAnnotation(annotations.name, annotations.description, target, serviceName, parameters, entityKey, operationKind, keyParams);
141
+ const restrictions = (0, utils_1.parseCdsRestrictions)(annotations.restrict, annotations.requires);
142
+ return new structures_1.McpToolAnnotation(annotations.name, annotations.description, target, serviceName, parameters, entityKey, operationKind, keyParams, restrictions);
131
143
  }
132
144
  /**
133
145
  * Constructs a prompt annotation from parsed annotation data
@@ -14,18 +14,22 @@ class McpAnnotation {
14
14
  _target;
15
15
  /** The name of the CAP service this annotation belongs to */
16
16
  _serviceName;
17
+ /** Auth roles by providing CDS that is required for use */
18
+ _restrictions;
17
19
  /**
18
20
  * Creates a new MCP annotation instance
19
21
  * @param name - Unique identifier for this annotation
20
22
  * @param description - Human-readable description
21
23
  * @param target - The target element this annotation applies to
22
24
  * @param serviceName - Name of the associated CAP service
25
+ * @param restrictions - Roles required for the given annotation
23
26
  */
24
- constructor(name, description, target, serviceName) {
27
+ constructor(name, description, target, serviceName, restrictions) {
25
28
  this._name = name;
26
29
  this._description = description;
27
30
  this._target = target;
28
31
  this._serviceName = serviceName;
32
+ this._restrictions = restrictions;
29
33
  }
30
34
  /**
31
35
  * Gets the unique name identifier for this annotation
@@ -55,6 +59,14 @@ class McpAnnotation {
55
59
  get serviceName() {
56
60
  return this._serviceName;
57
61
  }
62
+ /**
63
+ * Gets the list of roles required for access to the annotation.
64
+ * If the list is empty, then all can access.
65
+ * @returns List of required roles
66
+ */
67
+ get restrictions() {
68
+ return this._restrictions;
69
+ }
58
70
  }
59
71
  exports.McpAnnotation = McpAnnotation;
60
72
  /**
@@ -79,9 +91,10 @@ class McpResourceAnnotation extends McpAnnotation {
79
91
  * @param functionalities - Set of enabled OData query options (filter, top, skip, etc.)
80
92
  * @param properties - Map of entity properties to their CDS types
81
93
  * @param resourceKeys - Map of key fields to their types
94
+ * @param restrictions - Optional restrictions based on CDS roles
82
95
  */
83
- constructor(name, description, target, serviceName, functionalities, properties, resourceKeys, wrap) {
84
- super(name, description, target, serviceName);
96
+ constructor(name, description, target, serviceName, functionalities, properties, resourceKeys, wrap, restrictions) {
97
+ super(name, description, target, serviceName, restrictions ?? []);
85
98
  this._functionalities = functionalities;
86
99
  this._properties = properties;
87
100
  this._resourceKeys = resourceKeys;
@@ -139,9 +152,10 @@ class McpToolAnnotation extends McpAnnotation {
139
152
  * @param entityKey - Optional entity key field for bound operations
140
153
  * @param operationKind - Optional operation type ('function' or 'action')
141
154
  * @param keyTypeMap - Optional map of key fields to types for bound operations
155
+ * @param restrictions - Optional restrictions based on CDS roles
142
156
  */
143
- constructor(name, description, operation, serviceName, parameters, entityKey, operationKind, keyTypeMap) {
144
- super(name, description, operation, serviceName);
157
+ constructor(name, description, operation, serviceName, parameters, entityKey, operationKind, keyTypeMap, restrictions) {
158
+ super(name, description, operation, serviceName, restrictions ?? []);
145
159
  this._parameters = parameters;
146
160
  this._entityKey = entityKey;
147
161
  this._operationKind = operationKind;
@@ -192,7 +206,7 @@ class McpPromptAnnotation extends McpAnnotation {
192
206
  * @param prompts - Array of prompt template definitions
193
207
  */
194
208
  constructor(name, description, serviceName, prompts) {
195
- super(name, description, serviceName, serviceName);
209
+ super(name, description, serviceName, serviceName, []);
196
210
  this._prompts = prompts;
197
211
  }
198
212
  /**
@@ -10,6 +10,7 @@ exports.determineResourceOptions = determineResourceOptions;
10
10
  exports.parseResourceElements = parseResourceElements;
11
11
  exports.parseOperationElements = parseOperationElements;
12
12
  exports.parseEntityKeys = parseEntityKeys;
13
+ exports.parseCdsRestrictions = parseCdsRestrictions;
13
14
  const constants_1 = require("./constants");
14
15
  const logger_1 = require("../logger");
15
16
  /**
@@ -191,3 +192,64 @@ function parseEntityKeys(definition) {
191
192
  }
192
193
  return result;
193
194
  }
195
+ /**
196
+ * Parses the CDS role restrictions to be used for MCP
197
+ */
198
+ function parseCdsRestrictions(restrictions, requires) {
199
+ if (!restrictions && !requires)
200
+ return [];
201
+ const result = [];
202
+ if (requires) {
203
+ result.push({
204
+ role: requires,
205
+ });
206
+ }
207
+ if (!restrictions || restrictions.length <= 0)
208
+ return result;
209
+ for (const el of restrictions) {
210
+ const ops = mapOperationRestriction(el.grant);
211
+ if (!el.to) {
212
+ result.push({
213
+ role: "authenticated-user",
214
+ operations: ops,
215
+ });
216
+ continue;
217
+ }
218
+ const mapped = el.to.map((to) => ({
219
+ role: to,
220
+ operations: ops,
221
+ }));
222
+ result.push(...mapped);
223
+ }
224
+ return result;
225
+ }
226
+ /**
227
+ * Maps the "grant" property from CdsRestriction to McpRestriction
228
+ */
229
+ function mapOperationRestriction(cdsRestrictions) {
230
+ const result = [];
231
+ if (!cdsRestrictions || cdsRestrictions.length <= 0) {
232
+ result.push("CREATE");
233
+ result.push("READ");
234
+ result.push("UPDATE");
235
+ result.push("DELETE");
236
+ return result;
237
+ }
238
+ for (const el of cdsRestrictions) {
239
+ switch (el) {
240
+ case "CHANGE":
241
+ result.push("UPDATE");
242
+ continue;
243
+ case "*":
244
+ result.push("CREATE");
245
+ result.push("READ");
246
+ result.push("UPDATE");
247
+ result.push("DELETE");
248
+ continue;
249
+ default:
250
+ result.push(el);
251
+ continue;
252
+ }
253
+ }
254
+ return result;
255
+ }
package/lib/auth/utils.js CHANGED
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isAuthEnabled = isAuthEnabled;
4
4
  exports.getAccessRights = getAccessRights;
5
5
  exports.registerAuthMiddleware = registerAuthMiddleware;
6
+ exports.hasToolOperationAccess = hasToolOperationAccess;
7
+ exports.getWrapAccesses = getWrapAccesses;
6
8
  const handler_1 = require("./handler");
7
9
  const proxyProvider_js_1 = require("@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js");
8
10
  const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js");
@@ -210,3 +212,61 @@ function configureOAuthProxy(expressApp) {
210
212
  serviceDocumentationUrl: new URL("https://docs.cloudfoundry.org/api/uaa/version/77.34.0/index.html#authorization"),
211
213
  }));
212
214
  }
215
+ /**
216
+ * Checks whether the requesting user's access matches that of the roles required
217
+ * @param user
218
+ * @returns true if the user has access
219
+ */
220
+ function hasToolOperationAccess(user, roles) {
221
+ // If no restrictions are defined, allow access
222
+ if (!roles || roles.length === 0)
223
+ return true;
224
+ for (const el of roles) {
225
+ if (user.is(el.role))
226
+ return true;
227
+ }
228
+ return false;
229
+ }
230
+ /**
231
+ * Determines wrap accesses based on the given MCP restrictions derived from annotations
232
+ * @param user
233
+ * @param restrictions
234
+ * @returns wrap tool accesses
235
+ */
236
+ function getWrapAccesses(user, restrictions) {
237
+ // If no restrictions are defined, allow all access
238
+ if (!restrictions || restrictions.length === 0) {
239
+ return {
240
+ canRead: true,
241
+ canCreate: true,
242
+ canUpdate: true,
243
+ canDelete: true,
244
+ };
245
+ }
246
+ const access = {};
247
+ for (const el of restrictions) {
248
+ // If the user does not even have the role then no reason to check
249
+ if (!user.is(el.role))
250
+ continue;
251
+ if (!el.operations || el.operations.length <= 0) {
252
+ access.canRead = true;
253
+ access.canCreate = true;
254
+ access.canDelete = true;
255
+ access.canUpdate = true;
256
+ break;
257
+ }
258
+ if (el.operations.includes("READ")) {
259
+ access.canRead = true;
260
+ }
261
+ if (el.operations.includes("UPDATE")) {
262
+ access.canUpdate = true;
263
+ }
264
+ if (el.operations.includes("CREATE")) {
265
+ access.canCreate = true;
266
+ }
267
+ if (el.operations.includes("DELETE")) {
268
+ access.canDelete = true;
269
+ }
270
+ }
271
+ return access;
272
+ }
@@ -35,6 +35,7 @@ function loadConfiguration() {
35
35
  "create",
36
36
  "update",
37
37
  ],
38
+ instructions: cdsEnv?.instructions,
38
39
  };
39
40
  }
40
41
  /**
@@ -66,28 +66,36 @@ const TIMEOUT_MS = 10_000; // Standard timeout for tool calls (ms)
66
66
  * Modes can be controlled globally via configuration and per-entity via @mcp.wrap.
67
67
  *
68
68
  * Example tool names (naming is explicit for easier LLM usage):
69
- * Service_Entity_query, Service_Entity_get, Service_Entity_create, Service_Entity_update
69
+ * Service_Entity_query, Service_Entity_get, Service_Entity_create, Service_Entity_update, Service_Entity_delete
70
70
  */
71
- function registerEntityWrappers(resAnno, server, authEnabled, defaultModes) {
71
+ function registerEntityWrappers(resAnno, server, authEnabled, defaultModes, accesses) {
72
72
  const CDS = global.cds;
73
73
  logger_1.LOGGER.debug(`[REGISTRATION TIME] Registering entity wrappers for ${resAnno.serviceName}.${resAnno.target}, available services:`, Object.keys(CDS.services || {}));
74
74
  const modes = resAnno.wrap?.modes ?? defaultModes;
75
- if (modes.includes("query")) {
75
+ if (modes.includes("query") && accesses.canRead) {
76
76
  registerQueryTool(resAnno, server, authEnabled);
77
77
  }
78
78
  if (modes.includes("get") &&
79
79
  resAnno.resourceKeys &&
80
- resAnno.resourceKeys.size > 0) {
80
+ resAnno.resourceKeys.size > 0 &&
81
+ accesses.canRead) {
81
82
  registerGetTool(resAnno, server, authEnabled);
82
83
  }
83
- if (modes.includes("create")) {
84
+ if (modes.includes("create") && accesses.canCreate) {
84
85
  registerCreateTool(resAnno, server, authEnabled);
85
86
  }
86
87
  if (modes.includes("update") &&
87
88
  resAnno.resourceKeys &&
88
- resAnno.resourceKeys.size > 0) {
89
+ resAnno.resourceKeys.size > 0 &&
90
+ accesses.canUpdate) {
89
91
  registerUpdateTool(resAnno, server, authEnabled);
90
92
  }
93
+ if (modes.includes("delete") &&
94
+ resAnno.resourceKeys &&
95
+ resAnno.resourceKeys.size > 0 &&
96
+ accesses.canDelete) {
97
+ registerDeleteTool(resAnno, server, authEnabled);
98
+ }
91
99
  }
92
100
  /**
93
101
  * Builds the visible tool name for a given operation mode.
@@ -474,6 +482,78 @@ function registerUpdateTool(resAnno, server, authEnabled) {
474
482
  };
475
483
  server.registerTool(toolName, { title: toolName, description: desc, inputSchema }, updateHandler);
476
484
  }
485
+ /**
486
+ * Registers the delete tool for an entity.
487
+ * Requires keys to identify the entity to delete.
488
+ */
489
+ function registerDeleteTool(resAnno, server, authEnabled) {
490
+ const toolName = nameFor(resAnno.serviceName, resAnno.target, "delete");
491
+ const inputSchema = {};
492
+ // Keys required for deletion
493
+ for (const [k, cdsType] of resAnno.resourceKeys.entries()) {
494
+ inputSchema[k] = (0, utils_2.determineMcpParameterType)(cdsType).describe(`Key ${k}`);
495
+ }
496
+ const keyList = Array.from(resAnno.resourceKeys.keys()).join(", ");
497
+ const hint = resAnno.wrap?.hint ? ` Hint: ${resAnno.wrap?.hint}` : "";
498
+ const desc = `Delete ${resAnno.target} by key(s): ${keyList}. This operation cannot be undone.${hint}`;
499
+ const deleteHandler = async (args) => {
500
+ const CDS = global.cds;
501
+ const { DELETE } = CDS.ql;
502
+ const svc = await resolveServiceInstance(resAnno.serviceName);
503
+ if (!svc) {
504
+ const msg = `Service not found: ${resAnno.serviceName}. Available: ${Object.keys(CDS.services || {}).join(", ")}`;
505
+ logger_1.LOGGER.error(msg);
506
+ return (0, utils_2.toolError)("ERR_MISSING_SERVICE", msg);
507
+ }
508
+ // Extract keys - similar to get/update handlers
509
+ const keys = {};
510
+ for (const [k] of resAnno.resourceKeys.entries()) {
511
+ let provided = args[k];
512
+ if (provided === undefined) {
513
+ // Case-insensitive key matching (like in get handler)
514
+ const alt = Object.entries(args || {}).find(([kk]) => String(kk).toLowerCase() === String(k).toLowerCase());
515
+ if (alt)
516
+ provided = args[alt[0]];
517
+ }
518
+ if (provided === undefined) {
519
+ logger_1.LOGGER.warn(`Delete tool missing required key`, { key: k, toolName });
520
+ return (0, utils_2.toolError)("MISSING_KEY", `Missing key '${k}'`);
521
+ }
522
+ // Coerce numeric strings (like in get handler)
523
+ const raw = provided;
524
+ keys[k] =
525
+ typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw;
526
+ }
527
+ logger_1.LOGGER.debug(`Executing DELETE on ${resAnno.target} with keys`, keys);
528
+ const tx = svc.tx({ user: (0, utils_1.getAccessRights)(authEnabled) });
529
+ try {
530
+ const response = await withTimeout(tx.run(DELETE.from(resAnno.target).where(keys)), TIMEOUT_MS, toolName, async () => {
531
+ try {
532
+ await tx.rollback();
533
+ }
534
+ catch { }
535
+ });
536
+ try {
537
+ await tx.commit();
538
+ }
539
+ catch { }
540
+ return (0, utils_2.asMcpResult)(response ?? { deleted: true });
541
+ }
542
+ catch (error) {
543
+ try {
544
+ await tx.rollback();
545
+ }
546
+ catch { }
547
+ const isTimeout = String(error?.message || "").includes("timed out");
548
+ const msg = isTimeout
549
+ ? `${toolName} timed out after ${TIMEOUT_MS}ms`
550
+ : `DELETE_FAILED: ${error?.message || String(error)}`;
551
+ logger_1.LOGGER.error(msg, error);
552
+ return (0, utils_2.toolError)(isTimeout ? "TIMEOUT" : "DELETE_FAILED", msg);
553
+ }
554
+ };
555
+ server.registerTool(toolName, { title: toolName, description: desc, inputSchema }, deleteHandler);
556
+ }
477
557
  // Helper: compile structured inputs into a CDS query
478
558
  // The function translates the validated MCP input into CQN safely,
479
559
  // including a basic escape of string literals to avoid invalid syntax.
@@ -24,7 +24,7 @@ function createMcpServer(config, annotations) {
24
24
  name: config.name,
25
25
  version: config.version,
26
26
  capabilities: config.capabilities,
27
- });
27
+ }, { instructions: config.instructions });
28
28
  if (!annotations) {
29
29
  logger_1.LOGGER.debug("No annotations provided, skipping registration...");
30
30
  return server;
@@ -33,20 +33,26 @@ function createMcpServer(config, annotations) {
33
33
  const authEnabled = (0, utils_1.isAuthEnabled)(config.auth);
34
34
  // Always register discovery tool for better model planning
35
35
  (0, describe_model_1.registerDescribeModelTool)(server);
36
+ const accessRights = (0, utils_1.getAccessRights)(authEnabled);
36
37
  for (const entry of annotations.values()) {
37
38
  if (entry instanceof structures_1.McpToolAnnotation) {
39
+ if (!(0, utils_1.hasToolOperationAccess)(accessRights, entry.restrictions))
40
+ continue;
38
41
  (0, tools_1.assignToolToServer)(entry, server, authEnabled);
39
42
  continue;
40
43
  }
41
44
  else if (entry instanceof structures_1.McpResourceAnnotation) {
42
- (0, resources_1.assignResourceToServer)(entry, server, authEnabled);
45
+ const accesses = (0, utils_1.getWrapAccesses)(accessRights, entry.restrictions);
46
+ if (accesses.canRead) {
47
+ (0, resources_1.assignResourceToServer)(entry, server, authEnabled);
48
+ }
43
49
  // Optionally expose entities as tools based on global/per-entity switches
44
50
  const globalWrap = !!config.wrap_entities_to_actions;
45
51
  const localWrap = entry.wrap?.tools;
46
52
  const enabled = localWrap === true || (localWrap === undefined && globalWrap);
47
53
  if (enabled) {
48
54
  const modes = config.wrap_entity_modes ?? ["query", "get"];
49
- (0, entity_tools_1.registerEntityWrappers)(entry, server, authEnabled, modes);
55
+ (0, entity_tools_1.registerEntityWrappers)(entry, server, authEnabled, modes, accesses);
50
56
  }
51
57
  continue;
52
58
  }
package/lib/mcp.js CHANGED
@@ -39,7 +39,7 @@ class McpPlugin {
39
39
  async onBootstrap(app) {
40
40
  logger_1.LOGGER.debug("Event received for 'bootstrap'");
41
41
  this.expressApp = app;
42
- this.expressApp.use(express_1.default.json());
42
+ this.expressApp.use("/mcp", express_1.default.json());
43
43
  if (this.config.auth === "inherit") {
44
44
  (0, utils_2.registerAuthMiddleware)(this.expressApp);
45
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavdi/cap-mcp",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "MCP Pluging for CAP",
5
5
  "keywords": [
6
6
  "MCP",