@cap-js/ord 1.3.14 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/templates.js CHANGED
@@ -1,15 +1,16 @@
1
1
  const cds = require("@sap/cds");
2
2
  const { hasSAPPolicyLevel } = require("./utils");
3
+ const { isMCPPluginReady } = require("./mcpAdapter");
3
4
  const defaults = require("./defaults");
4
5
  const _ = require("lodash");
5
6
  const {
6
- AUTHENTICATION_TYPE,
7
7
  DATA_PRODUCT_ANNOTATION,
8
8
  DATA_PRODUCT_SIMPLE_ANNOTATION,
9
9
  DATA_PRODUCT_TYPE,
10
10
  DESCRIPTION_PREFIX,
11
11
  ENTITY_RELATIONSHIP_ANNOTATION,
12
12
  LEVEL,
13
+ MCP_CUSTOM_TYPE,
13
14
  ORD_EXTENSIONS_PREFIX,
14
15
  ORD_ODM_ENTITY_NAME_ANNOTATION,
15
16
  ORD_RESOURCE_TYPE,
@@ -21,7 +22,8 @@ const {
21
22
  CONTENT_MERGE_KEY,
22
23
  CDS_ELEMENT_KIND,
23
24
  } = require("./constants");
24
- const { Logger } = require("./logger");
25
+ const Logger = require("./logger");
26
+ const { ensureAccessStrategies } = require("./access-strategies");
25
27
 
26
28
  function unflatten(flattedObject) {
27
29
  let result = {};
@@ -66,8 +68,13 @@ const _generatePaths = (srv, srvDefinition) => {
66
68
 
67
69
  //putting OData as default in case of non-supported protocol
68
70
  if (paths.length === 0) {
69
- srvDefinition["@odata"] = true;
70
- paths.push({ kind: "odata", path: protocols.path4(srvDefinition) });
71
+ if (isPrimaryDataProductService(srvDefinition)) {
72
+ // Data product services use REST protocol, not OData
73
+ paths.push({ kind: "rest", path: protocols.path4(srvDefinition) });
74
+ } else {
75
+ srvDefinition["@odata"] = true;
76
+ paths.push({ kind: "odata", path: protocols.path4(srvDefinition) });
77
+ }
71
78
  }
72
79
 
73
80
  return paths;
@@ -152,29 +159,32 @@ function _getEntityVersion(entity) {
152
159
  }
153
160
 
154
161
  /**
155
- * This is a function to create a resource definition object.
162
+ * Pure template function for creating ORD resource definitions.
163
+ * This function does not make assumptions about access strategies - they must be provided explicitly.
164
+ * Access strategies are validated and will fallback to 'open' in non-strict mode if missing.
165
+ *
156
166
  * @param {string} resourceType The type of the resource.
157
167
  * @param {string} mediaType The media type of the resource.
158
168
  * @param {string} ordId The ordId of the resource.
159
169
  * @param {string} serviceName The name of the service.
160
170
  * @param {string} fileExtension The file extension of the resource.
161
- * @param {Array} accessStrategies The array of accessStrategies objects
171
+ * @param {Array} accessStrategies The array of accessStrategies objects (required, no default)
162
172
  * @returns {Object} A resource definition object.
163
173
  * @private
164
174
  */
165
- function _getResourceDefinition(
166
- resourceType,
167
- mediaType,
168
- ordId,
169
- serviceName,
170
- fileExtension,
171
- accessStrategies = [{ type: AUTHENTICATION_TYPE.Open }],
172
- ) {
175
+ function _getResourceDefinition(resourceType, mediaType, ordId, serviceName, fileExtension, accessStrategies) {
176
+ // Validate and ensure access strategies are present
177
+ // In non-strict mode, this will log error and fallback to 'open'
178
+ // In strict mode (cds.env.ord.strictAccessStrategies = true), this will throw
179
+ const validatedStrategies = ensureAccessStrategies(accessStrategies, {
180
+ resourceName: `${serviceName} (${resourceType})`,
181
+ });
182
+
173
183
  return {
174
184
  type: resourceType,
175
185
  mediaType: `application/${mediaType}`,
176
186
  url: `/ord/v1/${ordId}/${serviceName}.${fileExtension}`,
177
- accessStrategies,
187
+ accessStrategies: validatedStrategies,
178
188
  };
179
189
  }
180
190
 
@@ -197,7 +207,7 @@ const createGroupsTemplateForService = (serviceName, serviceDefinition, appConfi
197
207
  const visibility = _handleVisibility(ordExtensions, serviceDefinition, appConfig.env?.defaultVisibility);
198
208
  if (visibility === RESOURCE_VISIBILITY.private) {
199
209
  // If the visibility of the service is private, do not create a group
200
- Logger.debug("Skipping group creation for private service:", serviceName);
210
+ Logger.info("Skipping group creation for private service:", serviceName);
201
211
  return undefined;
202
212
  }
203
213
 
@@ -581,6 +591,107 @@ function _getPackageID(namespace, packageIds, resourceType, visibility = RESOURC
581
591
  return packageIds.find((id) => id.includes(`-${resourceType}-`)) || packageIds.find((id) => id.includes(namespace));
582
592
  }
583
593
 
594
+ /**
595
+ * Helper function to create a single MCP API resource.
596
+ *
597
+ * @param {object} metadata - The MCP metadata object.
598
+ * @param {object} appConfig - The application configuration.
599
+ * @param {Array} packageIds - The available package identifiers.
600
+ * @param {Array} accessStrategies - The array of accessStrategies objects.
601
+ * @returns {Object} An MCP API resource object.
602
+ */
603
+ function createSingleMCPResource(metadata, appConfig, packageIds, accessStrategies) {
604
+ const visibility = metadata?.visibility || RESOURCE_VISIBILITY.public;
605
+
606
+ // Generate ordId based on visibility
607
+ let resourceId;
608
+ if (visibility === RESOURCE_VISIBILITY.public) {
609
+ resourceId = "mcp-server"; // Default public resource
610
+ } else {
611
+ resourceId = `mcp-server-${visibility}`; // mcp-server-internal, mcp-server-private
612
+ }
613
+
614
+ const ordId = `${appConfig.ordNamespace}:apiResource:${resourceId}:v1`;
615
+
616
+ // Use metadata with proper fallbacks
617
+ const title = metadata?.title || `MCP Server for ${appConfig.appName}`;
618
+ const shortDescription =
619
+ metadata?.shortDescription || `This is the MCP server to interact with the ${appConfig.appName}`;
620
+ const description = metadata?.description || `This is the MCP server to interact with the ${appConfig.appName}`;
621
+ const version = metadata?.version || "1.0.0";
622
+
623
+ // Handle entryPoints: convert string to array, with fallback
624
+ const entryPoints = metadata?.entryPoints ? metadata.entryPoints : ["/rest/mcp/streaming"];
625
+
626
+ const packageId = _getPackageID(appConfig.ordNamespace, packageIds, ORD_RESOURCE_TYPE.api, visibility);
627
+ // Fallback: ensure partOfPackage never undefined to satisfy generic apiResources tests
628
+ const effectivePackageId = packageId || `${appConfig.ordNamespace}:package:${visibility}:v1`;
629
+
630
+ return {
631
+ ordId,
632
+ title,
633
+ shortDescription,
634
+ description,
635
+ version,
636
+ lastUpdate: appConfig.lastUpdate,
637
+ visibility,
638
+ partOfPackage: effectivePackageId,
639
+ releaseStatus: "active",
640
+ apiProtocol: "mcp",
641
+ resourceDefinitions: [
642
+ {
643
+ type: "custom",
644
+ customType: MCP_CUSTOM_TYPE,
645
+ mediaType: "application/json",
646
+ url: `/ord/v1/${ordId}/mcp-server-definition.mcp.json`,
647
+ accessStrategies,
648
+ },
649
+ ],
650
+ entryPoints,
651
+ extensible: { supported: "no" },
652
+ };
653
+ }
654
+
655
+ /**
656
+ * This is a template function to create MCP API Resource object(s).
657
+ *
658
+ * The generated MCP API resource(s) can be customized via the generic overwrite mechanism
659
+ * using custom.ord.json. Properties like visibility, title, version, entryPoints, etc.
660
+ * can be overridden by matching the ordId pattern.
661
+ *
662
+ * Metadata values are sourced from the MCP plugin's generateORDMetadata function when available,
663
+ * which reads from cds.env.mcp configuration. This allows users to customize MCP metadata via
664
+ * package.json or .cdsrc.json files.
665
+ *
666
+ * @param {object} appConfig - The application configuration.
667
+ * @param {Array} packageIds - The available package identifiers.
668
+ * @param {Array} accessStrategies The array of accessStrategies objects
669
+ * @returns {Object|Array} An MCP API resource object or array of objects.
670
+ */
671
+ const createMCPAPIResourceTemplate = (appConfig, packageIds, accessStrategies) => {
672
+ // Get ORD metadata from MCP plugin if available
673
+ let ordMetadata = null;
674
+ // Use comprehensive check for MCP plugin readiness
675
+ const shouldAddMCP = isMCPPluginReady();
676
+ if (shouldAddMCP) {
677
+ try {
678
+ const { generateORDMetadata } = require("@btp-ai/mcp-plugin/lib/utils/metadata");
679
+ ordMetadata = generateORDMetadata();
680
+ } catch (error) {
681
+ Logger.warn("Failed to generate MCP ORD metadata:", error.message);
682
+ }
683
+ }
684
+
685
+ // Handle both array and single object responses from MCP plugin
686
+ if (Array.isArray(ordMetadata)) {
687
+ return ordMetadata.map((metadata) =>
688
+ createSingleMCPResource(metadata, appConfig, packageIds, accessStrategies),
689
+ );
690
+ } else {
691
+ return createSingleMCPResource(ordMetadata || {}, appConfig, packageIds, accessStrategies);
692
+ }
693
+ };
694
+
584
695
  function _propagateORDVisibility(model) {
585
696
  for (const [name, def] of Object.entries(model.definitions)) {
586
697
  if (def.kind === CDS_ELEMENT_KIND.service && def[ORD_EXTENSIONS_PREFIX + "visibility"]) {
@@ -606,6 +717,7 @@ module.exports = {
606
717
  createGroupsTemplateForService,
607
718
  createAPIResourceTemplate,
608
719
  createEventResourceTemplate,
720
+ createMCPAPIResourceTemplate,
609
721
  _getPackageID,
610
722
  _getEntityTypeMappings,
611
723
  _getExposedEntityTypes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/ord",
3
- "version": "1.3.14",
3
+ "version": "1.4.1",
4
4
  "description": "CAP Plugin for generating ORD document.",
5
5
  "repository": "cap-js/ord",
6
6
  "author": "SAP SE (https://www.sap.com)",
@@ -9,7 +9,7 @@
9
9
  "workspaces": [
10
10
  "xmpl",
11
11
  "xmpl_java",
12
- "__tests__/integration-test-app"
12
+ "__tests__/integration/integration-test-app"
13
13
  ],
14
14
  "main": "cds-plugin.js",
15
15
  "files": [
@@ -20,20 +20,23 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "lint": "npx eslint .",
23
- "test": "jest --ci --collectCoverage",
24
- "test:integration": "jest __tests__/integration.test.js --testTimeout=30000 --testPathIgnorePatterns=/node_modules/ --forceExit",
23
+ "test": "jest __tests__/unit --ci --collectCoverage",
24
+ "test:integration:basic": "jest __tests__/integration/basic-auth.test.js --testPathIgnorePatterns=/node_modules/ --forceExit",
25
+ "test:integration:mtls": "jest __tests__/integration/mtls-auth.test.js --testPathIgnorePatterns=/node_modules/ --forceExit",
25
26
  "update-snapshot": "jest --ci --updateSnapshot",
26
27
  "cds:version": "cds v -i"
27
28
  },
28
29
  "devDependencies": {
29
30
  "eslint": "^9.2.0",
31
+ "express": "^4",
30
32
  "jest": "^30.0.0",
31
- "prettier": "3.6.2",
32
- "supertest": "^7.0.0"
33
+ "prettier": "3.7.4",
34
+ "supertest": "^7.0.0",
35
+ "@cap-js/sqlite": "^2",
36
+ "@sap/cds-dk": ">=8.9.5"
33
37
  },
34
38
  "peerDependencies": {
35
- "@sap/cds": ">=8.9.4",
36
- "@sap/cds-dk": ">=8.9.5"
39
+ "@sap/cds": ">=8.9.4"
37
40
  },
38
41
  "dependencies": {
39
42
  "@cap-js/asyncapi": "^1.0.3",
@@ -1,153 +0,0 @@
1
- const cds = require("@sap/cds");
2
- const { AUTHENTICATION_TYPE, BASIC_AUTH_HEADER_KEY, AUTH_TYPE_ORD_ACCESS_STRATEGY_MAP } = require("./constants");
3
- const { Logger } = require("./logger");
4
- const bcrypt = require("bcryptjs");
5
-
6
- /**
7
- * Compares a plain text password with a hashed password
8
- * @param {string} password Plain text password to check
9
- * @param {string} hashedPassword Hashed password to compare against
10
- * @returns {Promise<boolean>} Promise resolving to true if passwords match, false otherwise
11
- */
12
- async function _comparePassword(password, hashedPassword) {
13
- if (!password || !hashedPassword) {
14
- throw new Error("Password and hashed password are required");
15
- }
16
- return await bcrypt.compare(password, hashedPassword.replace(/^\$2y/, "$2a"));
17
- }
18
-
19
- /**
20
- * Validates if a string is a bcrypt hash
21
- * @param {string} hash String to validate
22
- * @returns {boolean} boolean indicating if the string is a bcrypt hash
23
- */
24
- function _isBcryptHash(hash) {
25
- return /^\$2[ayb]\$\d{2}\$[A-Za-z0-9./]{53}$/.test(hash);
26
- }
27
-
28
- /**
29
- * Create authentication configuration based on data given in the environment variables.
30
- * @returns {Object} Authentication configuration object or default configuration object as a fallback.
31
- */
32
- function createAuthConfig() {
33
- const defaultAuthConfig = {
34
- types: [AUTHENTICATION_TYPE.Open],
35
- accessStrategies: [{ type: AUTHENTICATION_TYPE.Open }],
36
- };
37
-
38
- try {
39
- const authConfig = {};
40
-
41
- authConfig.types = process.env.ORD_AUTH_TYPE
42
- ? [...new Set(JSON.parse(process.env.ORD_AUTH_TYPE))]
43
- : [...new Set(cds.env.authentication?.types)];
44
-
45
- if (!authConfig.types || authConfig.types.length === 0) {
46
- Logger.error("createAuthConfig:", 'No authorization type is provided. Defaulting to "Open" authentication');
47
- return defaultAuthConfig;
48
- }
49
-
50
- if (authConfig.types.some((authType) => !Object.values(AUTHENTICATION_TYPE).includes(authType))) {
51
- return Object.assign(defaultAuthConfig, { error: "Invalid authentication type" });
52
- }
53
-
54
- if (
55
- authConfig.types.includes(AUTHENTICATION_TYPE.Open) &&
56
- authConfig.types.includes(AUTHENTICATION_TYPE.Basic)
57
- ) {
58
- return Object.assign(defaultAuthConfig, {
59
- error: "Open authentication cannot be combined with any other authentication type",
60
- });
61
- }
62
-
63
- if (authConfig.types.includes(AUTHENTICATION_TYPE.Basic)) {
64
- const credentials = process.env.BASIC_AUTH
65
- ? JSON.parse(process.env.BASIC_AUTH)
66
- : cds.env.authentication.credentials;
67
-
68
- // Check all passwords in credentials map
69
- for (const [username, password] of Object.entries(credentials)) {
70
- if (!_isBcryptHash(password)) {
71
- Logger.error("createAuthConfig:", `Password for user "${username}" must be a bcrypt hash`);
72
- return Object.assign(defaultAuthConfig, { error: "All passwords must be bcrypt hashes" });
73
- }
74
- }
75
- // Store the complete credentials map
76
- authConfig.credentials = credentials;
77
- }
78
- authConfig.accessStrategies = authConfig.types.map((type) => ({
79
- type: AUTH_TYPE_ORD_ACCESS_STRATEGY_MAP[type],
80
- }));
81
- return authConfig;
82
- } catch (error) {
83
- return Object.assign(defaultAuthConfig, { error: error.message });
84
- }
85
- }
86
-
87
- /**
88
- * Validate, store authentication configuration in cds.context if undefined, and return the context.
89
- */
90
- function getAuthConfig() {
91
- if (cds.context?.authConfig) return cds.context?.authConfig;
92
-
93
- const authConfig = createAuthConfig();
94
-
95
- if (authConfig.error) {
96
- Logger.error("Authentication configuration error: " + authConfig.error);
97
- throw new Error("Invalid authentication configuration");
98
- }
99
-
100
- // set the context
101
- cds.context = {
102
- authConfig,
103
- };
104
- return cds.context?.authConfig;
105
- }
106
-
107
- /**
108
- * Middleware to authenticate the request based on the authentication configuration.
109
- */
110
- async function authenticate(req, res, next) {
111
- const authConfig = cds.context.authConfig;
112
-
113
- if (authConfig.types.includes(AUTHENTICATION_TYPE.Open)) {
114
- res.status(200);
115
- return next();
116
- }
117
-
118
- if (
119
- !Object.keys(req.headers).includes(BASIC_AUTH_HEADER_KEY) &&
120
- authConfig.types.includes(AUTHENTICATION_TYPE.Basic)
121
- ) {
122
- return res.status(401).setHeader("WWW-Authenticate", 'Basic realm="401"').send("Authentication required.");
123
- }
124
-
125
- if (req.headers[BASIC_AUTH_HEADER_KEY] && authConfig.types.includes(AUTHENTICATION_TYPE.Basic)) {
126
- const authHeader = req.headers[BASIC_AUTH_HEADER_KEY];
127
-
128
- if (!authHeader.startsWith("Basic ")) {
129
- return res.status(401).send("Invalid authentication type");
130
- }
131
-
132
- const [username, password] = Buffer.from(authHeader.split(" ")[1], "base64").toString().split(":");
133
- const credentials = authConfig.credentials;
134
- const storedPassword = credentials[username];
135
-
136
- if (storedPassword && (await _comparePassword(password, storedPassword))) {
137
- res.status(200);
138
- return next();
139
- } else {
140
- return res.status(401).send("Invalid credentials");
141
- }
142
- }
143
-
144
- // In other cases, the request is unauthorized.
145
- // TODO: Add support for UCL-mTLS authorization.
146
- return res.status(401).send("Not authorized");
147
- }
148
-
149
- module.exports = {
150
- authenticate,
151
- createAuthConfig,
152
- getAuthConfig,
153
- };