@cap-js/ord 1.4.4 → 1.5.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 +14 -0
- package/lib/auth/mtls-endpoint-service.js +1 -1
- package/lib/build.js +74 -77
- package/lib/constants.js +18 -7
- package/lib/extendOrdWithCustom.js +0 -30
- package/lib/integrationDependency.js +127 -0
- package/lib/interopCsn.js +17 -3
- package/lib/metaData.js +21 -15
- package/lib/ord-service.js +10 -4
- package/lib/ord.js +13 -30
- package/lib/protocol-resolver.js +45 -41
- package/lib/templates.js +18 -104
- package/lib/threads/compile.js +10 -0
- package/package.json +9 -7
- package/lib/mcpAdapter.js +0 -132
package/README.md
CHANGED
|
@@ -231,6 +231,20 @@ const ord = cds.compile.to.ord(csn);
|
|
|
231
231
|
|
|
232
232
|
Build all ord related documents, including ordDocument and services resources files:
|
|
233
233
|
|
|
234
|
+
|
|
235
|
+
##### Supported Options
|
|
236
|
+
|
|
237
|
+
1. **workers**:
|
|
238
|
+
* **Description**: Max number of workers to use during execution
|
|
239
|
+
* **Supported Values**:
|
|
240
|
+
* _**workers=N**_ - max number of N (int) workers (e.g. 2 workers => _**workers=2**_)
|
|
241
|
+
* _**workers=NC**_ - max N (int/float) workers per CPU core (e.g. max 5 workers on a 2 core machine, max 10 workers on a 4 core machine => _**workers=2.5C**_)
|
|
242
|
+
* **Default Value**: max 1 worker per every 2 CPU cores => _**workers=0.5C**_
|
|
243
|
+
* **Usage Examples**:
|
|
244
|
+
* `cds build --for ord --opts 'workers=2'`
|
|
245
|
+
* `cds build --for ord --opts 'workers=2.5C'`
|
|
246
|
+
* `cds build --for ord --opts 'workers=0.5C'` (equivalent to `cds build --for ord`)
|
|
247
|
+
|
|
234
248
|
```sh
|
|
235
249
|
cds build --for ord
|
|
236
250
|
|
|
@@ -48,7 +48,7 @@ async function fetchMtlsCertInfo(endpoint, timeoutMs) {
|
|
|
48
48
|
} catch (error) {
|
|
49
49
|
clearTimeout(timeoutId);
|
|
50
50
|
if (error.name === "AbortError") {
|
|
51
|
-
throw new Error(`Request timeout after ${timeoutMs}ms
|
|
51
|
+
throw new Error(`Request timeout after ${timeoutMs}ms`, { cause: error });
|
|
52
52
|
}
|
|
53
53
|
throw error;
|
|
54
54
|
}
|
package/lib/build.js
CHANGED
|
@@ -1,83 +1,64 @@
|
|
|
1
|
-
const cds = require("@sap/cds");
|
|
2
1
|
const path = require("path");
|
|
2
|
+
const cds = require("@sap/cds");
|
|
3
3
|
const _ = require("lodash");
|
|
4
|
-
const
|
|
4
|
+
const Piscina = require("piscina");
|
|
5
5
|
const cliProgress = require("cli-progress");
|
|
6
|
-
const { BUILD_DEFAULT_PATH, ORD_SERVICE_NAME, ORD_DOCUMENT_FILE_NAME } = require("./constants");
|
|
7
|
-
const { isMCPPluginInPackageJson } = require("./mcpAdapter");
|
|
8
6
|
|
|
9
|
-
const
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const { ord } = require("./index");
|
|
9
|
+
const { BUILD_DEFAULT_PATH, ORD_DOCUMENT_FILE_NAME } = require("./constants");
|
|
10
10
|
|
|
11
11
|
module.exports = class OrdBuildPlugin extends cds.build.Plugin {
|
|
12
12
|
static taskDefaults = { src: cds.env.folders.srv };
|
|
13
13
|
|
|
14
14
|
init() {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
this.task.dest = this.task.dest ?? path.join(cds.root, BUILD_DEFAULT_PATH);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async build() {
|
|
19
|
+
// @cap-js/graphql registers protocols and compile targets at runtime via cds-plugin.js,
|
|
20
|
+
// but cds build rebuilds cds.env afterward, losing both registrations.
|
|
21
|
+
// Re-apply so endpoints4() and cds.compile.to.graphql work during build.
|
|
22
|
+
if ("@cap-js/graphql" in cds.env.plugins && !cds.env.protocols?.graphql) {
|
|
23
|
+
cds.env.protocols.graphql = { path: "/graphql", impl: "@cap-js/graphql" };
|
|
24
|
+
require("@cap-js/graphql/lib/api").registerCompileTargets();
|
|
17
25
|
}
|
|
26
|
+
|
|
27
|
+
return this.model()
|
|
28
|
+
.then((model) => ({ model, document: ord(model) }))
|
|
29
|
+
.then(({ model, document }) =>
|
|
30
|
+
Promise.all([
|
|
31
|
+
this.write(this._postProcess(document)).to(ORD_DOCUMENT_FILE_NAME),
|
|
32
|
+
this._generateResourcesFiles(model, [
|
|
33
|
+
...(document.apiResources || []),
|
|
34
|
+
...(document.eventResources || []),
|
|
35
|
+
]),
|
|
36
|
+
]),
|
|
37
|
+
)
|
|
38
|
+
.catch((error) => {
|
|
39
|
+
throw new cds.build.BuildError(`ORD build failed: ${error.message}`);
|
|
40
|
+
});
|
|
18
41
|
}
|
|
19
42
|
|
|
20
|
-
_createProgressBar(
|
|
21
|
-
|
|
43
|
+
_createProgressBar() {
|
|
44
|
+
return new cliProgress.SingleBar({
|
|
22
45
|
format: "Processing resourcesFiles [{bar}] {percentage}% | {value}/{total} | ETA: {eta}s",
|
|
23
46
|
barCompleteChar: "█",
|
|
24
47
|
barIncompleteChar: "░",
|
|
25
48
|
stopOnComplete: true,
|
|
26
49
|
});
|
|
27
|
-
progressBar.start(totalFiles, 0);
|
|
28
|
-
return progressBar;
|
|
29
50
|
}
|
|
30
51
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (!resource.ordId.includes(ORD_SERVICE_NAME) && resource.resourceDefinitions) {
|
|
34
|
-
return total + resource.resourceDefinitions.length;
|
|
35
|
-
}
|
|
36
|
-
return total;
|
|
37
|
-
}, 0);
|
|
38
|
-
}
|
|
52
|
+
_postProcess(document) {
|
|
53
|
+
const clone = _.cloneDeep(document);
|
|
39
54
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
for (const resource of resObj) {
|
|
47
|
-
const shouldGenerate = resource.resourceDefinitions || isMCPPluginInPackageJson();
|
|
48
|
-
if (!shouldGenerate) continue;
|
|
49
|
-
|
|
50
|
-
for (const resourceDefinition of resource.resourceDefinitions) {
|
|
51
|
-
const { _, response } = await getMetadata(resourceDefinition.url, model); // eslint-disable-line no-unused-vars
|
|
52
|
-
const fileName = path
|
|
53
|
-
.join(resource.ordId, resourceDefinition.url.split("/").pop())
|
|
54
|
-
.replace(/:/g, "_");
|
|
55
|
-
promises.push(this.write(response).to(fileName));
|
|
56
|
-
progressBar.update(++completed);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
await Promise.all(promises);
|
|
60
|
-
} finally {
|
|
61
|
-
progressBar.stop();
|
|
62
|
-
}
|
|
63
|
-
}
|
|
55
|
+
[...(clone.apiResources || []), ...(clone.eventResources || [])]
|
|
56
|
+
.flatMap((resource) => resource.resourceDefinitions || [])
|
|
57
|
+
.forEach((resourceDefinition) => {
|
|
58
|
+
resourceDefinition.url = this._createRelativePath(resourceDefinition.url);
|
|
59
|
+
});
|
|
64
60
|
|
|
65
|
-
|
|
66
|
-
const clonedOrdDocument = _.cloneDeep(ordDocument);
|
|
67
|
-
const _updateResourceUrls = (resources) => {
|
|
68
|
-
for (const resource of resources || []) {
|
|
69
|
-
if (resource.resourceDefinitions) {
|
|
70
|
-
for (const resourceDefinition of resource.resourceDefinitions) {
|
|
71
|
-
resourceDefinition.url = this._createRelativePath(resourceDefinition.url);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
_updateResourceUrls(clonedOrdDocument.apiResources);
|
|
78
|
-
_updateResourceUrls(clonedOrdDocument.eventResources);
|
|
79
|
-
|
|
80
|
-
return clonedOrdDocument;
|
|
61
|
+
return clone;
|
|
81
62
|
}
|
|
82
63
|
|
|
83
64
|
_createRelativePath(url) {
|
|
@@ -86,26 +67,42 @@ module.exports = class OrdBuildPlugin extends cds.build.Plugin {
|
|
|
86
67
|
return path.join(...relative.replace(/:/g, "_").split("/"));
|
|
87
68
|
}
|
|
88
69
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const ordDocument = ord(model);
|
|
93
|
-
const postProcessedOrdDocument = this.postProcess(ordDocument);
|
|
94
|
-
|
|
95
|
-
const promises = [];
|
|
96
|
-
promises.push(this.write(postProcessedOrdDocument).to(ORD_DOCUMENT_FILE_NAME));
|
|
70
|
+
_createWorkerPool(tasks, model) {
|
|
71
|
+
const size = cds.cli?.options?.taskOptions?.workers || os.availableParallelism();
|
|
72
|
+
const maxThreads = Math.ceil(Number(size) || os.availableParallelism() * Number(size.replace(/C$/i, "")));
|
|
97
73
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
74
|
+
return new Piscina({
|
|
75
|
+
filename: path.join(__dirname, "threads", "compile.js"),
|
|
76
|
+
workerData: { model: JSON.parse(JSON.stringify(model)) },
|
|
77
|
+
minThreads: Math.min(tasks, maxThreads, Math.ceil(os.availableParallelism() * 1.5)),
|
|
78
|
+
maxThreads: Math.min(tasks, maxThreads, Math.ceil(os.availableParallelism() * 1.5)),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
101
81
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
82
|
+
_extractCompileTasks(resources) {
|
|
83
|
+
return resources
|
|
84
|
+
.filter(({ resourceDefinitions }) => !!resourceDefinitions)
|
|
85
|
+
.flatMap(({ ordId, resourceDefinitions }) => resourceDefinitions.map(({ url }) => ({ url, ordId })));
|
|
86
|
+
}
|
|
105
87
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
88
|
+
_generateResourcesFiles(model, resources) {
|
|
89
|
+
const progressBar = this._createProgressBar();
|
|
90
|
+
const tasks = this._extractCompileTasks(resources);
|
|
91
|
+
const pool = !tasks.length ? null : this._createWorkerPool(tasks.length, model);
|
|
92
|
+
|
|
93
|
+
progressBar.start(tasks.length, 0);
|
|
94
|
+
|
|
95
|
+
return Promise.all(
|
|
96
|
+
tasks.map(({ url, ordId }) =>
|
|
97
|
+
pool.run({ url }).then((response) =>
|
|
98
|
+
this.write(response)
|
|
99
|
+
.to(path.join(ordId, url.split("/").pop()).replace(/:/g, "_"))
|
|
100
|
+
.then(() => progressBar.increment()),
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
).finally(() => {
|
|
104
|
+
progressBar.stop();
|
|
105
|
+
return pool?.close({ force: true });
|
|
106
|
+
});
|
|
110
107
|
}
|
|
111
108
|
};
|
package/lib/constants.js
CHANGED
|
@@ -53,6 +53,7 @@ const COMPILER_TYPES = Object.freeze({
|
|
|
53
53
|
edmx: "edmx",
|
|
54
54
|
csn: "csn",
|
|
55
55
|
mcp: "mcp",
|
|
56
|
+
graphql: "graphql",
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
const CONTENT_MERGE_KEY = "ordId";
|
|
@@ -115,7 +116,13 @@ const SHORT_DESCRIPTION_PREFIX = "Short description of ";
|
|
|
115
116
|
const SEM_VERSION_REGEX =
|
|
116
117
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
117
118
|
|
|
118
|
-
const
|
|
119
|
+
const MCP_RESOURCE_DEFINITION_TYPE = "sap:mcp-server-card:v0";
|
|
120
|
+
|
|
121
|
+
const EXTERNAL_DP_ORD_ID_ANNOTATION = "@cds.dp.ordId";
|
|
122
|
+
|
|
123
|
+
const EXTERNAL_SERVICE_ANNOTATION = "@cds.external";
|
|
124
|
+
|
|
125
|
+
const INTEGRATION_DEPENDENCY_RESOURCE_NAME = "externalDependencies";
|
|
119
126
|
|
|
120
127
|
// ORD apiProtocol values
|
|
121
128
|
const ORD_API_PROTOCOL = Object.freeze({
|
|
@@ -125,21 +132,23 @@ const ORD_API_PROTOCOL = Object.freeze({
|
|
|
125
132
|
GRAPHQL: "graphql",
|
|
126
133
|
SAP_INA: "sap-ina-api-v1",
|
|
127
134
|
SAP_DATA_SUBSCRIPTION: "sap.dp:data-subscription-api:v1",
|
|
135
|
+
MCP: "mcp",
|
|
128
136
|
});
|
|
129
137
|
|
|
130
138
|
// Mapping from CAP protocol kind to ORD apiProtocol
|
|
131
|
-
// CAP may return 'odata', 'odata-v4', 'rest', etc.
|
|
139
|
+
// CAP may return 'odata', 'odata-v4', 'rest', 'graphql', etc.
|
|
132
140
|
const CAP_TO_ORD_PROTOCOL_MAP = Object.freeze({
|
|
133
141
|
"odata": ORD_API_PROTOCOL.ODATA_V4,
|
|
134
142
|
"odata-v4": ORD_API_PROTOCOL.ODATA_V4,
|
|
135
143
|
"odata-v2": ORD_API_PROTOCOL.ODATA_V2,
|
|
136
144
|
"rest": ORD_API_PROTOCOL.REST,
|
|
145
|
+
"graphql": ORD_API_PROTOCOL.GRAPHQL,
|
|
137
146
|
});
|
|
138
147
|
|
|
139
|
-
// Protocols that ORD supports but CAP
|
|
148
|
+
// Protocols that ORD supports but CAP may not recognize (endpoints4 returns [])
|
|
140
149
|
// These need special handling in the ORD plugin
|
|
141
150
|
const ORD_ONLY_PROTOCOLS = Object.freeze({
|
|
142
|
-
|
|
151
|
+
ina: {
|
|
143
152
|
apiProtocol: ORD_API_PROTOCOL.SAP_INA,
|
|
144
153
|
hasEntryPoints: false,
|
|
145
154
|
hasResourceDefinitions: false,
|
|
@@ -147,8 +156,7 @@ const ORD_ONLY_PROTOCOLS = Object.freeze({
|
|
|
147
156
|
});
|
|
148
157
|
|
|
149
158
|
// Protocols that the ORD plugin cannot currently generate definitions for
|
|
150
|
-
|
|
151
|
-
const PLUGIN_UNSUPPORTED_PROTOCOLS = Object.freeze(["graphql"]);
|
|
159
|
+
const PLUGIN_UNSUPPORTED_PROTOCOLS = Object.freeze([]);
|
|
152
160
|
|
|
153
161
|
// CF mTLS Error Reasons
|
|
154
162
|
const CF_MTLS_ERROR_REASON = Object.freeze({
|
|
@@ -189,8 +197,11 @@ module.exports = {
|
|
|
189
197
|
DATA_PRODUCT_TYPE,
|
|
190
198
|
DESCRIPTION_PREFIX,
|
|
191
199
|
ENTITY_RELATIONSHIP_ANNOTATION,
|
|
200
|
+
EXTERNAL_DP_ORD_ID_ANNOTATION,
|
|
201
|
+
EXTERNAL_SERVICE_ANNOTATION,
|
|
202
|
+
INTEGRATION_DEPENDENCY_RESOURCE_NAME,
|
|
192
203
|
LEVEL,
|
|
193
|
-
|
|
204
|
+
MCP_RESOURCE_DEFINITION_TYPE,
|
|
194
205
|
OPEN_RESOURCE_DISCOVERY_VERSION,
|
|
195
206
|
OPENAPI_SERVERS_ANNOTATION,
|
|
196
207
|
ORD_ACCESS_STRATEGY,
|
|
@@ -20,37 +20,7 @@ function patchGeneratedOrdResources(destinationObj, sourceObj) {
|
|
|
20
20
|
const destObj = _.keyBy(destinationObj, CONTENT_MERGE_KEY);
|
|
21
21
|
const srcObj = _.keyBy(sourceObj, CONTENT_MERGE_KEY);
|
|
22
22
|
|
|
23
|
-
// Helper: extract MCP merge logic for clarity & reuse
|
|
24
|
-
const mergeMCPResource = (ordId, existingKey) => {
|
|
25
|
-
const baseKey = ordId in destObj ? ordId : existingKey;
|
|
26
|
-
if (baseKey) {
|
|
27
|
-
const base = structuredClone(destObj[baseKey]);
|
|
28
|
-
const override = structuredClone(srcObj[ordId]);
|
|
29
|
-
const merged = {
|
|
30
|
-
...base, // preserve generated structural fields
|
|
31
|
-
...override, // overlay custom changes
|
|
32
|
-
apiProtocol: "mcp", // enforce protocol
|
|
33
|
-
partOfPackage: base.partOfPackage || override.partOfPackage,
|
|
34
|
-
resourceDefinitions: base.resourceDefinitions || override.resourceDefinitions,
|
|
35
|
-
};
|
|
36
|
-
if (baseKey !== ordId) delete destObj[baseKey];
|
|
37
|
-
destObj[ordId] = merged;
|
|
38
|
-
} else {
|
|
39
|
-
// MCP plugin not available: do not introduce brand-new MCP resource purely from custom file
|
|
40
|
-
// This preserves contract: MCP resource only exists when plugin is available.
|
|
41
|
-
// Silently skip or log (warn) to avoid test noise.
|
|
42
|
-
// Logger.warn could be added here if desired.
|
|
43
|
-
return; // skip adding
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const existingMCPKey = Object.keys(destObj).find((k) => destObj[k]?.apiProtocol === "mcp");
|
|
48
|
-
|
|
49
23
|
for (const ordId in srcObj) {
|
|
50
|
-
if (ordId.endsWith(":apiResource:mcp-server:v1")) {
|
|
51
|
-
mergeMCPResource(ordId, existingMCPKey);
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
24
|
destObj[ordId] =
|
|
55
25
|
ordId in destObj
|
|
56
26
|
? _.assignWith(structuredClone(destObj[ordId]), structuredClone(srcObj[ordId]))
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const {
|
|
2
|
+
DATA_PRODUCT_SIMPLE_ANNOTATION,
|
|
3
|
+
EXTERNAL_DP_ORD_ID_ANNOTATION,
|
|
4
|
+
EXTERNAL_SERVICE_ANNOTATION,
|
|
5
|
+
INTEGRATION_DEPENDENCY_RESOURCE_NAME,
|
|
6
|
+
ORD_RESOURCE_TYPE,
|
|
7
|
+
RESOURCE_VISIBILITY,
|
|
8
|
+
} = require("./constants");
|
|
9
|
+
const { readORDExtensions, _getPackageID } = require("./templates");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parses @cds.dp.ordId annotation to extract resource information.
|
|
13
|
+
* @param {string} ordId - e.g., "sap.sai:apiResource:Supplier:v1"
|
|
14
|
+
* @returns {Object} { namespace, resourceType, resourceName, version }
|
|
15
|
+
*/
|
|
16
|
+
function parseDataProductOrdId(ordId) {
|
|
17
|
+
const [namespace, resourceType, resourceName, version = "v1"] = ordId.split(":");
|
|
18
|
+
return { namespace, resourceType, resourceName, version };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Checks if a CSN definition is an external Data Product.
|
|
23
|
+
* @param {Object} definition - CSN definition object
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isExternalDataProduct(definition) {
|
|
27
|
+
return !!(
|
|
28
|
+
definition.kind === "service" &&
|
|
29
|
+
definition[EXTERNAL_SERVICE_ANNOTATION] &&
|
|
30
|
+
definition[DATA_PRODUCT_SIMPLE_ANNOTATION] &&
|
|
31
|
+
definition[EXTERNAL_DP_ORD_ID_ANNOTATION]
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Collects external services from CSN definitions.
|
|
37
|
+
* Returns a flat list of external services (one per service, no namespace grouping).
|
|
38
|
+
* @param {Object} csn - The CSN definitions object
|
|
39
|
+
* @returns {Array} Array of external service objects
|
|
40
|
+
*/
|
|
41
|
+
function collectExternalServices(csn) {
|
|
42
|
+
const externalServices = [];
|
|
43
|
+
|
|
44
|
+
for (const [serviceName, definition] of Object.entries(csn.definitions)) {
|
|
45
|
+
if (!isExternalDataProduct(definition)) continue;
|
|
46
|
+
|
|
47
|
+
const dpOrdId = definition[EXTERNAL_DP_ORD_ID_ANNOTATION];
|
|
48
|
+
const { resourceType, version } = parseDataProductOrdId(dpOrdId);
|
|
49
|
+
|
|
50
|
+
// Only support apiResource for now
|
|
51
|
+
if (resourceType !== "apiResource") continue;
|
|
52
|
+
|
|
53
|
+
externalServices.push({
|
|
54
|
+
serviceName,
|
|
55
|
+
ordId: dpOrdId,
|
|
56
|
+
minVersion: version.replace("v", "") + ".0.0",
|
|
57
|
+
definition,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return externalServices;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates a single IntegrationDependency with one aspect per external service.
|
|
66
|
+
* @param {Array} externalServices - Array of external service objects
|
|
67
|
+
* @param {Object} appConfig - The application configuration
|
|
68
|
+
* @param {Array} packageIds - The available package identifiers
|
|
69
|
+
* @returns {Object} IntegrationDependency object
|
|
70
|
+
*/
|
|
71
|
+
function createIntegrationDependency(externalServices, appConfig, packageIds) {
|
|
72
|
+
const packageId = _getPackageID(
|
|
73
|
+
appConfig.ordNamespace,
|
|
74
|
+
packageIds,
|
|
75
|
+
ORD_RESOURCE_TYPE.integrationDependency,
|
|
76
|
+
RESOURCE_VISIBILITY.public,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Create one aspect per external service
|
|
80
|
+
const aspects = externalServices.map((service) => {
|
|
81
|
+
const ordExtensions = readORDExtensions(service.definition || {});
|
|
82
|
+
return {
|
|
83
|
+
title: service.serviceName,
|
|
84
|
+
mandatory: false,
|
|
85
|
+
apiResources: [{ ordId: service.ordId, minVersion: service.minVersion }],
|
|
86
|
+
...ordExtensions, // Allow customization via @ORD.Extensions on the service
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Read IntegrationDependency level config from cdsrc
|
|
91
|
+
const integrationDepConfig = appConfig.env?.integrationDependency || {};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ordId: `${appConfig.ordNamespace}:${ORD_RESOURCE_TYPE.integrationDependency}:${INTEGRATION_DEPENDENCY_RESOURCE_NAME}:v1`,
|
|
95
|
+
title: "External Dependencies",
|
|
96
|
+
version: "1.0.0",
|
|
97
|
+
releaseStatus: "active",
|
|
98
|
+
visibility: RESOURCE_VISIBILITY.public,
|
|
99
|
+
mandatory: false,
|
|
100
|
+
partOfPackage: packageId,
|
|
101
|
+
aspects,
|
|
102
|
+
...integrationDepConfig, // Allow customization via cdsrc
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Generates a single IntegrationDependency for all external services.
|
|
108
|
+
* @param {Object} csn - The CSN definitions object
|
|
109
|
+
* @param {Object} appConfig - The application configuration
|
|
110
|
+
* @param {Array} packageIds - The available package identifiers
|
|
111
|
+
* @returns {Array} Array containing single IntegrationDependency or empty array
|
|
112
|
+
*/
|
|
113
|
+
function getIntegrationDependencies(csn, appConfig, packageIds) {
|
|
114
|
+
const externalServices = collectExternalServices(csn);
|
|
115
|
+
if (externalServices.length === 0) return [];
|
|
116
|
+
|
|
117
|
+
return [createIntegrationDependency(externalServices, appConfig, packageIds)];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
getIntegrationDependencies,
|
|
122
|
+
// Exported for testing
|
|
123
|
+
collectExternalServices,
|
|
124
|
+
createIntegrationDependency,
|
|
125
|
+
parseDataProductOrdId,
|
|
126
|
+
isExternalDataProduct,
|
|
127
|
+
};
|
package/lib/interopCsn.js
CHANGED
|
@@ -117,6 +117,12 @@ function map_annotations(csn) {
|
|
|
117
117
|
delete o[oldA];
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
+
|
|
121
|
+
// delete all @assert.unique annotations
|
|
122
|
+
let assertUniqueAnnos = Object.keys(o).filter(x => x.startsWith('@assert.unique'))
|
|
123
|
+
for (let a of assertUniqueAnnos) {
|
|
124
|
+
delete o[a]
|
|
125
|
+
}
|
|
120
126
|
}
|
|
121
127
|
}
|
|
122
128
|
|
|
@@ -126,10 +132,18 @@ function map_annotations(csn) {
|
|
|
126
132
|
function add_meta_info(csn) {
|
|
127
133
|
if (typeof csn != "object") return csn; // needed to make tests pass
|
|
128
134
|
csn["csnInteropEffective"] = "1.0";
|
|
129
|
-
csn.meta ??= {};
|
|
135
|
+
csn.meta ??= {}; // ensure there is a meta section
|
|
136
|
+
|
|
137
|
+
// Keep only the properties of csn.meta that are relevant for interop:
|
|
138
|
+
// "flavor", "document", "features", and private extension names (starting with "__")
|
|
139
|
+
// Remove everything else, including "creator" (leaks build details) and
|
|
140
|
+
// any unknown compiler-added properties (e.g. "compilerCsnFlavor").
|
|
141
|
+
for (let k in csn.meta) {
|
|
142
|
+
if (!["flavor", "document", "features"].includes(k) && !k.startsWith("__")) {
|
|
143
|
+
delete csn.meta[k];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
130
146
|
csn.meta.flavor = "effective";
|
|
131
|
-
// Remove compiler creator information – not relevant for ORD interop and leaks build details
|
|
132
|
-
if (csn.meta.creator) delete csn.meta.creator;
|
|
133
147
|
|
|
134
148
|
let services = Object.entries(csn.definitions).filter(([, def]) => def.kind === "service");
|
|
135
149
|
if (services.length === 1) {
|
package/lib/metaData.js
CHANGED
|
@@ -5,7 +5,6 @@ const { COMPILER_TYPES, OPENAPI_SERVERS_ANNOTATION } = require("./constants");
|
|
|
5
5
|
const Logger = require("./logger");
|
|
6
6
|
const { interopCSN } = require("./interopCsn.js");
|
|
7
7
|
const cdsc = require("@sap/cds-compiler/lib/main");
|
|
8
|
-
const { isMCPPluginReady, buildMcpServerDefinition } = require("./mcpAdapter");
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* Read @OpenAPI.servers annotation from service definition
|
|
@@ -46,7 +45,7 @@ const getMetadata = async (url, model = null) => {
|
|
|
46
45
|
|
|
47
46
|
responseFile = openapi(csn, openapiOptions);
|
|
48
47
|
} catch (error) {
|
|
49
|
-
Logger.error(
|
|
48
|
+
Logger.error(`OpenApi error for service ${serviceName} - ${error.message}`);
|
|
50
49
|
throw error;
|
|
51
50
|
}
|
|
52
51
|
break;
|
|
@@ -54,7 +53,7 @@ const getMetadata = async (url, model = null) => {
|
|
|
54
53
|
try {
|
|
55
54
|
responseFile = asyncapi(csn, { ...options, ...(compileOptions?.asyncapi || {}) });
|
|
56
55
|
} catch (error) {
|
|
57
|
-
Logger.error(
|
|
56
|
+
Logger.error(`AsyncApi error for service ${serviceName} - ${error.message}`);
|
|
58
57
|
throw error;
|
|
59
58
|
}
|
|
60
59
|
break;
|
|
@@ -64,7 +63,7 @@ const getMetadata = async (url, model = null) => {
|
|
|
64
63
|
let effCsn = cdsc.for.effective(csn, opt_eff);
|
|
65
64
|
responseFile = interopCSN(effCsn);
|
|
66
65
|
} catch (error) {
|
|
67
|
-
Logger.error(
|
|
66
|
+
Logger.error(`Csn error for service ${serviceName} - ${error.message}`);
|
|
68
67
|
throw error;
|
|
69
68
|
}
|
|
70
69
|
break;
|
|
@@ -72,29 +71,36 @@ const getMetadata = async (url, model = null) => {
|
|
|
72
71
|
try {
|
|
73
72
|
responseFile = await cds.compile(csn).to["edmx"]({ ...options, ...(compileOptions?.edmx || {}) });
|
|
74
73
|
} catch (error) {
|
|
75
|
-
Logger.error(
|
|
74
|
+
Logger.error(`Edmx error for service ${serviceName} - ${error.message}`);
|
|
76
75
|
throw error;
|
|
77
76
|
}
|
|
78
77
|
break;
|
|
79
78
|
case COMPILER_TYPES.mcp:
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
try {
|
|
80
|
+
responseFile = await cds.compile(csn).to["mcp"]({ ...options, ...(compileOptions?.mcp || {}) });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
Logger.error("MCP error:", error.message);
|
|
83
|
+
throw error;
|
|
82
84
|
}
|
|
85
|
+
break;
|
|
86
|
+
case COMPILER_TYPES.graphql:
|
|
83
87
|
try {
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
const { generateSchema4 } = require("@cap-js/graphql/lib/schema");
|
|
89
|
+
const { lexicographicSortSchema, printSchema } = require("graphql");
|
|
90
|
+
const linked = cds.linked(csn);
|
|
91
|
+
const srv = new cds.ApplicationService(serviceName, linked);
|
|
92
|
+
let schema = generateSchema4({ [serviceName]: srv });
|
|
93
|
+
schema = lexicographicSortSchema(schema);
|
|
94
|
+
responseFile = printSchema(schema);
|
|
90
95
|
} catch (error) {
|
|
91
|
-
Logger.error("
|
|
96
|
+
Logger.error("GraphQL SDL error:", error.message);
|
|
92
97
|
throw error;
|
|
93
98
|
}
|
|
94
99
|
break;
|
|
95
100
|
}
|
|
96
101
|
return {
|
|
97
|
-
contentType:
|
|
102
|
+
contentType:
|
|
103
|
+
compilerType === "graphql" ? "text/plain" : `application/${compilerType === "edmx" ? "xml" : "json"}`,
|
|
98
104
|
response: responseFile,
|
|
99
105
|
};
|
|
100
106
|
};
|
package/lib/ord-service.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const cds = require("@sap/cds");
|
|
2
2
|
const ord = require("./ord.js");
|
|
3
|
-
const
|
|
3
|
+
const compileMetadata = require("./metaData.js");
|
|
4
4
|
const defaults = require("./defaults.js");
|
|
5
5
|
const { createAuthConfig, createAuthMiddleware } = require("./auth/authentication.js");
|
|
6
6
|
const Logger = require("./logger.js");
|
|
@@ -31,15 +31,21 @@ class OpenResourceDiscoveryService extends cds.ApplicationService {
|
|
|
31
31
|
return res.status(404).send("404 Not Found");
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Handler for metadata requests (oas3, edmx, csn, etc.)
|
|
35
|
+
const metadataHandler = async (req, res) => {
|
|
35
36
|
try {
|
|
36
|
-
const { contentType, response } = await
|
|
37
|
+
const { contentType, response } = await compileMetadata(req.url);
|
|
37
38
|
return res.status(200).contentType(contentType).send(response);
|
|
38
39
|
} catch (error) {
|
|
39
40
|
Logger.error(error, "Error while processing the resource definition document");
|
|
40
41
|
return res.status(500).send(error.message);
|
|
41
42
|
}
|
|
42
|
-
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Use separate routes instead of optional parameters for path-to-regexp v8 compatibility
|
|
46
|
+
cds.app.get(`/ord/v1/:ordId/:service`, authMiddleware, metadataHandler);
|
|
47
|
+
cds.app.get(`/ord/v1/:ordId`, authMiddleware, metadataHandler);
|
|
48
|
+
cds.app.get(`/ord/v1`, authMiddleware, metadataHandler);
|
|
43
49
|
|
|
44
50
|
return super.init();
|
|
45
51
|
}
|
package/lib/ord.js
CHANGED
|
@@ -11,13 +11,12 @@ const {
|
|
|
11
11
|
createEntityTypeMappingsItemTemplate,
|
|
12
12
|
createEventResourceTemplate,
|
|
13
13
|
createGroupsTemplateForService,
|
|
14
|
-
createMCPAPIResourceTemplate,
|
|
15
14
|
_propagateORDVisibility,
|
|
16
15
|
} = require("./templates");
|
|
16
|
+
const { getIntegrationDependencies } = require("./integrationDependency");
|
|
17
17
|
const { extendCustomORDContentIfExists } = require("./extendOrdWithCustom");
|
|
18
18
|
const { getRFC3339Date } = require("./date");
|
|
19
19
|
const { createAuthConfig } = require("./auth/authentication");
|
|
20
|
-
const { isMCPPluginReady } = require("./mcpAdapter");
|
|
21
20
|
|
|
22
21
|
const Logger = require("./logger");
|
|
23
22
|
const _ = require("lodash");
|
|
@@ -25,24 +24,6 @@ const cds = require("@sap/cds");
|
|
|
25
24
|
const defaults = require("./defaults");
|
|
26
25
|
const path = require("path");
|
|
27
26
|
|
|
28
|
-
const _addMCPResourceIfAvailable = (apiResources, appConfig, packageIds, accessStrategies) => {
|
|
29
|
-
// Use comprehensive check for MCP plugin readiness
|
|
30
|
-
const shouldAddMCP = isMCPPluginReady();
|
|
31
|
-
if (shouldAddMCP) {
|
|
32
|
-
try {
|
|
33
|
-
const mcpResources = createMCPAPIResourceTemplate(appConfig, packageIds, accessStrategies);
|
|
34
|
-
// Handle both array and single object responses from MCP plugin
|
|
35
|
-
if (Array.isArray(mcpResources)) {
|
|
36
|
-
apiResources.push(...mcpResources);
|
|
37
|
-
} else if (mcpResources) {
|
|
38
|
-
apiResources.push(mcpResources);
|
|
39
|
-
}
|
|
40
|
-
} catch (error) {
|
|
41
|
-
Logger.warn("Failed to create MCP API resource:", error.message);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
|
|
46
27
|
const initializeAppConfig = (csn) => {
|
|
47
28
|
const packageJson = _loadPackageJson();
|
|
48
29
|
const packageName = packageJson.name;
|
|
@@ -260,9 +241,6 @@ const _getAPIResources = (csn, appConfig, packageIds, accessStrategies) => {
|
|
|
260
241
|
),
|
|
261
242
|
);
|
|
262
243
|
|
|
263
|
-
// Conditionally add MCP API resource if plugin is available
|
|
264
|
-
_addMCPResourceIfAvailable(apiResources, appConfig, packageIds, accessStrategies);
|
|
265
|
-
|
|
266
244
|
return apiResources;
|
|
267
245
|
};
|
|
268
246
|
|
|
@@ -330,18 +308,17 @@ function _filterUnusedPackages(ordDocument) {
|
|
|
330
308
|
|
|
331
309
|
const usedPackageIds = new Set();
|
|
332
310
|
|
|
333
|
-
ordDocument.apiResources?.forEach((api) => usedPackageIds
|
|
334
|
-
ordDocument.eventResources?.forEach((event) => usedPackageIds
|
|
335
|
-
ordDocument.dataProducts?.forEach((dp) => usedPackageIds
|
|
311
|
+
ordDocument.apiResources?.forEach((api) => usedPackageIds.add(api.partOfPackage));
|
|
312
|
+
ordDocument.eventResources?.forEach((event) => usedPackageIds.add(event.partOfPackage));
|
|
313
|
+
ordDocument.dataProducts?.forEach((dp) => usedPackageIds.add(dp.partOfPackage));
|
|
314
|
+
ordDocument.integrationDependencies?.forEach((dep) => usedPackageIds.add(dep.partOfPackage));
|
|
336
315
|
ordDocument.entityTypes?.forEach((et) => {
|
|
337
|
-
if (et
|
|
316
|
+
if (et?.partOfPackage) {
|
|
338
317
|
usedPackageIds.add(et.partOfPackage);
|
|
339
318
|
}
|
|
340
319
|
});
|
|
341
320
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
return filteredPackages;
|
|
321
|
+
return ordDocument.packages.filter((pkg) => usedPackageIds.has(pkg.ordId));
|
|
345
322
|
}
|
|
346
323
|
|
|
347
324
|
module.exports = (csn) => {
|
|
@@ -375,6 +352,12 @@ module.exports = (csn) => {
|
|
|
375
352
|
ordDocument.eventResources = eventResources;
|
|
376
353
|
}
|
|
377
354
|
}
|
|
355
|
+
|
|
356
|
+
const integrationDependencies = getIntegrationDependencies(linkedCsn, appConfig, packageIds);
|
|
357
|
+
if (integrationDependencies.length) {
|
|
358
|
+
ordDocument.integrationDependencies = integrationDependencies;
|
|
359
|
+
}
|
|
360
|
+
|
|
378
361
|
ordDocument = extendCustomORDContentIfExists(appConfig, ordDocument);
|
|
379
362
|
ordDocument.packages = _filterUnusedPackages(ordDocument);
|
|
380
363
|
|
package/lib/protocol-resolver.js
CHANGED
|
@@ -23,14 +23,14 @@ function _getCapEndpoints(serviceName, srvDefinition) {
|
|
|
23
23
|
* Reads the explicit @protocol annotation from service definition.
|
|
24
24
|
*
|
|
25
25
|
* @param {Object} srvDefinition The service definition object.
|
|
26
|
-
* @returns {string
|
|
26
|
+
* @returns {string[]} Array of protocol names, or empty array if not explicitly set.
|
|
27
27
|
*/
|
|
28
28
|
function _getExplicitProtocol(srvDefinition) {
|
|
29
29
|
const protocol = srvDefinition["@protocol"];
|
|
30
30
|
if (!protocol) {
|
|
31
|
-
return
|
|
31
|
+
return [];
|
|
32
32
|
}
|
|
33
|
-
return Array.isArray(protocol) ? protocol
|
|
33
|
+
return Array.isArray(protocol) ? protocol : [protocol];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -38,7 +38,7 @@ function _getExplicitProtocol(srvDefinition) {
|
|
|
38
38
|
*
|
|
39
39
|
* Design Principles:
|
|
40
40
|
* - explicit protocol is the "master switch" for all decisions
|
|
41
|
-
* - Rule A: Explicit protocol + empty endpoints → don't
|
|
41
|
+
* - Rule A: Explicit protocol + empty endpoints → don't fall back to OData
|
|
42
42
|
* - Rule B: Only fallback to OData when no explicit protocol
|
|
43
43
|
* - Rule C: Never produce [null] in entryPoints
|
|
44
44
|
*
|
|
@@ -62,34 +62,8 @@ function resolveApiResourceProtocol(serviceName, srvDefinition, options = {}) {
|
|
|
62
62
|
];
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const explicit = _getExplicitProtocol(srvDefinition);
|
|
66
|
-
|
|
67
|
-
// 2. Handle explicit protocol
|
|
68
|
-
if (explicit) {
|
|
69
|
-
// 2a. Check if it's an ORD-only protocol (e.g., INA)
|
|
70
|
-
if (ORD_ONLY_PROTOCOLS[explicit]) {
|
|
71
|
-
const config = ORD_ONLY_PROTOCOLS[explicit];
|
|
72
|
-
const path = config.hasEntryPoints ? cds.service.protocols.path4(srvDefinition) : null;
|
|
73
|
-
return [
|
|
74
|
-
{
|
|
75
|
-
apiProtocol: config.apiProtocol,
|
|
76
|
-
entryPoints: path ? [path] : [],
|
|
77
|
-
hasResourceDefinitions: config.hasResourceDefinitions,
|
|
78
|
-
},
|
|
79
|
-
];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// 2b. Check if it's a plugin-unsupported protocol
|
|
83
|
-
if (PLUGIN_UNSUPPORTED_PROTOCOLS.includes(explicit)) {
|
|
84
|
-
Logger.warn(
|
|
85
|
-
`Protocol '${explicit}' is supported by ORD but this plugin cannot generate its resource definitions yet.`,
|
|
86
|
-
);
|
|
87
|
-
return [];
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// 3. Try to resolve from CAP endpoints
|
|
92
65
|
const capEndpoints = _getCapEndpoints(serviceName, srvDefinition);
|
|
66
|
+
const ordProtocols = [];
|
|
93
67
|
for (const endpoint of capEndpoints) {
|
|
94
68
|
if (PLUGIN_UNSUPPORTED_PROTOCOLS.includes(endpoint.kind)) {
|
|
95
69
|
Logger.warn(
|
|
@@ -100,22 +74,52 @@ function resolveApiResourceProtocol(serviceName, srvDefinition, options = {}) {
|
|
|
100
74
|
|
|
101
75
|
const apiProtocol = CAP_TO_ORD_PROTOCOL_MAP[endpoint.kind] ?? endpoint.kind;
|
|
102
76
|
if (apiProtocol) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
},
|
|
109
|
-
];
|
|
77
|
+
ordProtocols.push({
|
|
78
|
+
apiProtocol,
|
|
79
|
+
entryPoints: endpoint.path ? [endpoint.path] : [],
|
|
80
|
+
hasResourceDefinitions: true,
|
|
81
|
+
});
|
|
110
82
|
}
|
|
111
83
|
}
|
|
112
84
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
85
|
+
const explicit = _getExplicitProtocol(srvDefinition);
|
|
86
|
+
for (const protocol of explicit) {
|
|
87
|
+
// 2. Handle explicit protocol
|
|
88
|
+
// 2a. Check if it's an ORD-only protocol (e.g., INA)
|
|
89
|
+
if (ORD_ONLY_PROTOCOLS[protocol]) {
|
|
90
|
+
const config = ORD_ONLY_PROTOCOLS[protocol];
|
|
91
|
+
const path = config.hasEntryPoints ? cds.service.protocols.path4(srvDefinition) : null;
|
|
92
|
+
ordProtocols.push({
|
|
93
|
+
apiProtocol: config.apiProtocol,
|
|
94
|
+
entryPoints: path ? [path] : [],
|
|
95
|
+
hasResourceDefinitions: config.hasResourceDefinitions,
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2b. Check if it's a plugin-unsupported protocol
|
|
101
|
+
if (PLUGIN_UNSUPPORTED_PROTOCOLS.includes(protocol)) {
|
|
102
|
+
Logger.warn(
|
|
103
|
+
`Protocol '${protocol}' is supported by ORD but this plugin cannot generate its resource definitions yet.`,
|
|
104
|
+
);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. Handle explicit protocol with no CAP endpoint (Rule A)
|
|
109
|
+
if (!ordProtocols.some((p) => p.apiProtocol === protocol) && !CAP_TO_ORD_PROTOCOL_MAP[protocol]) {
|
|
110
|
+
Logger.warn(`Unknown protocol '${protocol}' is not supported, and skipped for service '${serviceName}'.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// no protocol found via CAP endpoints, but explicit protocol(s) defined → Rule A: don't fall back to OData, return empty array
|
|
115
|
+
if (ordProtocols.length === 0 && explicit.length > 0) {
|
|
116
116
|
return [];
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
if (ordProtocols.length > 0) {
|
|
120
|
+
return ordProtocols;
|
|
121
|
+
}
|
|
122
|
+
|
|
119
123
|
// 5. No explicit protocol and no CAP endpoint - fallback to OData (Rule B)
|
|
120
124
|
const path = cds.service.protocols.path4(srvDefinition);
|
|
121
125
|
return [
|
package/lib/templates.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const { hasSAPPolicyLevel } = require("./utils");
|
|
2
|
-
const { isMCPPluginReady } = require("./mcpAdapter");
|
|
3
2
|
const defaults = require("./defaults");
|
|
4
3
|
const _ = require("lodash");
|
|
5
4
|
const {
|
|
@@ -9,7 +8,6 @@ const {
|
|
|
9
8
|
DESCRIPTION_PREFIX,
|
|
10
9
|
ENTITY_RELATIONSHIP_ANNOTATION,
|
|
11
10
|
LEVEL,
|
|
12
|
-
MCP_CUSTOM_TYPE,
|
|
13
11
|
ORD_EXTENSIONS_PREFIX,
|
|
14
12
|
ORD_ODM_ENTITY_NAME_ANNOTATION,
|
|
15
13
|
ORD_RESOURCE_TYPE,
|
|
@@ -21,6 +19,7 @@ const {
|
|
|
21
19
|
CONTENT_MERGE_KEY,
|
|
22
20
|
CDS_ELEMENT_KIND,
|
|
23
21
|
ORD_API_PROTOCOL,
|
|
22
|
+
MCP_RESOURCE_DEFINITION_TYPE,
|
|
24
23
|
} = require("./constants");
|
|
25
24
|
const Logger = require("./logger");
|
|
26
25
|
const { ensureAccessStrategies } = require("./access-strategies");
|
|
@@ -348,6 +347,22 @@ const createAPIResourceTemplate = (serviceName, serviceDefinition, appConfig, pa
|
|
|
348
347
|
resourceDefinitions = [
|
|
349
348
|
_getResourceDefinition("openapi-v3", "json", ordId, serviceName, "oas3.json", accessStrategies),
|
|
350
349
|
];
|
|
350
|
+
} else if (apiProtocol === ORD_API_PROTOCOL.MCP) {
|
|
351
|
+
resourceDefinitions = [
|
|
352
|
+
_getResourceDefinition(MCP_RESOURCE_DEFINITION_TYPE, "json", ordId, serviceName, "mcp.json", accessStrategies),
|
|
353
|
+
];
|
|
354
|
+
} else if (apiProtocol === ORD_API_PROTOCOL.GRAPHQL) {
|
|
355
|
+
// GraphQL only has GraphQL SDL
|
|
356
|
+
resourceDefinitions = [
|
|
357
|
+
{
|
|
358
|
+
type: "graphql-sdl",
|
|
359
|
+
mediaType: "text/plain",
|
|
360
|
+
url: `/ord/v1/${ordId}/${serviceName}.graphql`,
|
|
361
|
+
accessStrategies: ensureAccessStrategies(accessStrategies, {
|
|
362
|
+
resourceName: `${serviceName} (graphql-sdl)`,
|
|
363
|
+
}),
|
|
364
|
+
},
|
|
365
|
+
];
|
|
351
366
|
} else {
|
|
352
367
|
// OData and others have both OpenAPI and EDMX
|
|
353
368
|
resourceDefinitions = [
|
|
@@ -572,107 +587,6 @@ function _getPackageID(namespace, packageIds, resourceType, visibility = RESOURC
|
|
|
572
587
|
return packageIds.find((id) => id.includes(`-${resourceType}-`)) || packageIds.find((id) => id.includes(namespace));
|
|
573
588
|
}
|
|
574
589
|
|
|
575
|
-
/**
|
|
576
|
-
* Helper function to create a single MCP API resource.
|
|
577
|
-
*
|
|
578
|
-
* @param {object} metadata - The MCP metadata object.
|
|
579
|
-
* @param {object} appConfig - The application configuration.
|
|
580
|
-
* @param {Array} packageIds - The available package identifiers.
|
|
581
|
-
* @param {Array} accessStrategies - The array of accessStrategies objects.
|
|
582
|
-
* @returns {Object} An MCP API resource object.
|
|
583
|
-
*/
|
|
584
|
-
function createSingleMCPResource(metadata, appConfig, packageIds, accessStrategies) {
|
|
585
|
-
const visibility = metadata?.visibility || RESOURCE_VISIBILITY.public;
|
|
586
|
-
|
|
587
|
-
// Generate ordId based on visibility
|
|
588
|
-
let resourceId;
|
|
589
|
-
if (visibility === RESOURCE_VISIBILITY.public) {
|
|
590
|
-
resourceId = "mcp-server"; // Default public resource
|
|
591
|
-
} else {
|
|
592
|
-
resourceId = `mcp-server-${visibility}`; // mcp-server-internal, mcp-server-private
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const ordId = `${appConfig.ordNamespace}:apiResource:${resourceId}:v1`;
|
|
596
|
-
|
|
597
|
-
// Use metadata with proper fallbacks
|
|
598
|
-
const title = metadata?.title || `MCP Server for ${appConfig.appName}`;
|
|
599
|
-
const shortDescription =
|
|
600
|
-
metadata?.shortDescription || `This is the MCP server to interact with the ${appConfig.appName}`;
|
|
601
|
-
const description = metadata?.description || `This is the MCP server to interact with the ${appConfig.appName}`;
|
|
602
|
-
const version = metadata?.version || "1.0.0";
|
|
603
|
-
|
|
604
|
-
// Handle entryPoints: convert string to array, with fallback
|
|
605
|
-
const entryPoints = metadata?.entryPoints ? metadata.entryPoints : ["/rest/mcp/streaming"];
|
|
606
|
-
|
|
607
|
-
const packageId = _getPackageID(appConfig.ordNamespace, packageIds, ORD_RESOURCE_TYPE.api, visibility);
|
|
608
|
-
// Fallback: ensure partOfPackage never undefined to satisfy generic apiResources tests
|
|
609
|
-
const effectivePackageId = packageId || `${appConfig.ordNamespace}:package:${visibility}:v1`;
|
|
610
|
-
|
|
611
|
-
return {
|
|
612
|
-
ordId,
|
|
613
|
-
title,
|
|
614
|
-
shortDescription,
|
|
615
|
-
description,
|
|
616
|
-
version,
|
|
617
|
-
lastUpdate: appConfig.lastUpdate,
|
|
618
|
-
visibility,
|
|
619
|
-
partOfPackage: effectivePackageId,
|
|
620
|
-
releaseStatus: "active",
|
|
621
|
-
apiProtocol: "mcp",
|
|
622
|
-
resourceDefinitions: [
|
|
623
|
-
{
|
|
624
|
-
type: "custom",
|
|
625
|
-
customType: MCP_CUSTOM_TYPE,
|
|
626
|
-
mediaType: "application/json",
|
|
627
|
-
url: `/ord/v1/${ordId}/mcp-server-definition.mcp.json`,
|
|
628
|
-
accessStrategies,
|
|
629
|
-
},
|
|
630
|
-
],
|
|
631
|
-
entryPoints,
|
|
632
|
-
extensible: { supported: "no" },
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
/**
|
|
637
|
-
* This is a template function to create MCP API Resource object(s).
|
|
638
|
-
*
|
|
639
|
-
* The generated MCP API resource(s) can be customized via the generic overwrite mechanism
|
|
640
|
-
* using custom.ord.json. Properties like visibility, title, version, entryPoints, etc.
|
|
641
|
-
* can be overridden by matching the ordId pattern.
|
|
642
|
-
*
|
|
643
|
-
* Metadata values are sourced from the MCP plugin's generateORDMetadata function when available,
|
|
644
|
-
* which reads from cds.env.mcp configuration. This allows users to customize MCP metadata via
|
|
645
|
-
* package.json or .cdsrc.json files.
|
|
646
|
-
*
|
|
647
|
-
* @param {object} appConfig - The application configuration.
|
|
648
|
-
* @param {Array} packageIds - The available package identifiers.
|
|
649
|
-
* @param {Array} accessStrategies The array of accessStrategies objects
|
|
650
|
-
* @returns {Object|Array} An MCP API resource object or array of objects.
|
|
651
|
-
*/
|
|
652
|
-
const createMCPAPIResourceTemplate = (appConfig, packageIds, accessStrategies) => {
|
|
653
|
-
// Get ORD metadata from MCP plugin if available
|
|
654
|
-
let ordMetadata = null;
|
|
655
|
-
// Use comprehensive check for MCP plugin readiness
|
|
656
|
-
const shouldAddMCP = isMCPPluginReady();
|
|
657
|
-
if (shouldAddMCP) {
|
|
658
|
-
try {
|
|
659
|
-
const { generateORDMetadata } = require("@btp-ai/mcp-plugin/lib/utils/metadata");
|
|
660
|
-
ordMetadata = generateORDMetadata();
|
|
661
|
-
} catch (error) {
|
|
662
|
-
Logger.warn("Failed to generate MCP ORD metadata:", error.message);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// Handle both array and single object responses from MCP plugin
|
|
667
|
-
if (Array.isArray(ordMetadata)) {
|
|
668
|
-
return ordMetadata.map((metadata) =>
|
|
669
|
-
createSingleMCPResource(metadata, appConfig, packageIds, accessStrategies),
|
|
670
|
-
);
|
|
671
|
-
} else {
|
|
672
|
-
return createSingleMCPResource(ordMetadata || {}, appConfig, packageIds, accessStrategies);
|
|
673
|
-
}
|
|
674
|
-
};
|
|
675
|
-
|
|
676
590
|
function _propagateORDVisibility(model) {
|
|
677
591
|
for (const [name, def] of Object.entries(model.definitions)) {
|
|
678
592
|
if (def.kind === CDS_ELEMENT_KIND.service && def[ORD_EXTENSIONS_PREFIX + "visibility"]) {
|
|
@@ -698,7 +612,7 @@ module.exports = {
|
|
|
698
612
|
createGroupsTemplateForService,
|
|
699
613
|
createAPIResourceTemplate,
|
|
700
614
|
createEventResourceTemplate,
|
|
701
|
-
|
|
615
|
+
readORDExtensions,
|
|
702
616
|
_getPackageID,
|
|
703
617
|
_getEntityTypeMappings,
|
|
704
618
|
_getExposedEntityTypes,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const { workerData } = require("piscina");
|
|
2
|
+
|
|
3
|
+
const compileMetadata = require("../metaData");
|
|
4
|
+
|
|
5
|
+
module.exports = ({ url }) => {
|
|
6
|
+
// JSON round-trip: CDS compiler output may contain Generator objects
|
|
7
|
+
// that cannot be transferred via postMessage (structured clone algorithm).
|
|
8
|
+
return compileMetadata(url, workerData.model) //
|
|
9
|
+
.then(({ response }) => JSON.parse(JSON.stringify(response)));
|
|
10
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/ord",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "CAP Plugin for generating ORD document.",
|
|
5
5
|
"repository": "cap-js/ord",
|
|
6
6
|
"author": "SAP SE (https://www.sap.com)",
|
|
@@ -28,13 +28,14 @@
|
|
|
28
28
|
"cds:version": "cds v -i"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"
|
|
31
|
+
"@cap-js/graphql": "^0.14.0",
|
|
32
|
+
"@cap-js/sqlite": "^2",
|
|
33
|
+
"@sap/cds-dk": ">=8.9.5",
|
|
34
|
+
"eslint": "^10.0.0",
|
|
32
35
|
"express": "^4",
|
|
33
36
|
"jest": "^30.0.0",
|
|
34
37
|
"prettier": "3.8.1",
|
|
35
|
-
"supertest": "^7.0.0"
|
|
36
|
-
"@cap-js/sqlite": "^2",
|
|
37
|
-
"@sap/cds-dk": ">=8.9.5"
|
|
38
|
+
"supertest": "^7.0.0"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
41
|
"@sap/cds": ">=8.9.4"
|
|
@@ -44,13 +45,14 @@
|
|
|
44
45
|
"@cap-js/openapi": "^1.2.1",
|
|
45
46
|
"bcryptjs": "3.0.3",
|
|
46
47
|
"cli-progress": "^3.12.0",
|
|
47
|
-
"lodash": "^4.17.21"
|
|
48
|
+
"lodash": "^4.17.21",
|
|
49
|
+
"piscina": "^5.1.4"
|
|
48
50
|
},
|
|
49
51
|
"engines": {
|
|
50
52
|
"node": ">=18 <23"
|
|
51
53
|
},
|
|
52
54
|
"overrides": {
|
|
53
|
-
"@sap/cds-compiler": "6.
|
|
55
|
+
"@sap/cds-compiler": "6.7.3"
|
|
54
56
|
},
|
|
55
57
|
"cds": {
|
|
56
58
|
"requires": {
|
package/lib/mcpAdapter.js
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
const Logger = require("./logger");
|
|
2
|
-
const cds = require("@sap/cds");
|
|
3
|
-
const path = require("path");
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* MCP Plugin Adapter
|
|
7
|
-
* Provides an abstraction layer for interacting with @btp-ai/mcp-plugin
|
|
8
|
-
* This allows for easy mocking and testing without requiring the actual plugin
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Load package.json from project root
|
|
13
|
-
* @returns {Object} Package.json content
|
|
14
|
-
* @throws {Error} If package.json is not found
|
|
15
|
-
*/
|
|
16
|
-
function _loadPackageJson() {
|
|
17
|
-
const packageJsonPath = path.join(cds.root, "package.json");
|
|
18
|
-
if (!cds.utils.exists(packageJsonPath)) {
|
|
19
|
-
throw new Error(`package.json not found in the project root directory`);
|
|
20
|
-
}
|
|
21
|
-
return require(packageJsonPath);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Check if MCP plugin is listed in package.json dependencies
|
|
26
|
-
* @param {Function} loadPackageJsonFn - Optional function to load package.json (for testing)
|
|
27
|
-
* @returns {boolean} True if plugin is in dependencies or devDependencies
|
|
28
|
-
*/
|
|
29
|
-
function isMCPPluginInPackageJson(loadPackageJsonFn = _loadPackageJson) {
|
|
30
|
-
try {
|
|
31
|
-
const packageJson = loadPackageJsonFn();
|
|
32
|
-
const allDependencies = {
|
|
33
|
-
...(packageJson.dependencies || {}),
|
|
34
|
-
...(packageJson.devDependencies || {}),
|
|
35
|
-
};
|
|
36
|
-
const isInstalled = "@btp-ai/mcp-plugin" in allDependencies;
|
|
37
|
-
if (isInstalled) {
|
|
38
|
-
Logger.log("MCP plugin found in package.json dependencies");
|
|
39
|
-
}
|
|
40
|
-
return isInstalled;
|
|
41
|
-
} catch (error) {
|
|
42
|
-
Logger.error("Could not check package.json for MCP plugin:", error.message);
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Check if MCP plugin is available at runtime
|
|
49
|
-
* @param {Function} resolveFunction - Optional custom resolve function for testing
|
|
50
|
-
* @returns {boolean} True if plugin is available
|
|
51
|
-
*/
|
|
52
|
-
function isMCPPluginAvailable(resolveFunction = require.resolve) {
|
|
53
|
-
try {
|
|
54
|
-
resolveFunction("@btp-ai/mcp-plugin");
|
|
55
|
-
Logger.log("MCP plugin is available at runtime");
|
|
56
|
-
return true;
|
|
57
|
-
} catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Get MCP plugin instance
|
|
64
|
-
* Returns null if plugin is not available
|
|
65
|
-
* @returns {Object|null} MCP plugin module or null
|
|
66
|
-
*/
|
|
67
|
-
function getMcpPlugin() {
|
|
68
|
-
if (!isMCPPluginAvailable()) {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
return require("@btp-ai/mcp-plugin");
|
|
74
|
-
} catch (error) {
|
|
75
|
-
Logger.error("Failed to load MCP plugin:", error.message);
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Check if MCP plugin is ready for use (both installed and available)
|
|
82
|
-
* This function combines the logic of both isMCPPluginInPackageJson and isMCPPluginAvailable
|
|
83
|
-
* to provide a comprehensive check for MCP plugin readiness.
|
|
84
|
-
* Maintains the original AND logic: both conditions must be true.
|
|
85
|
-
*
|
|
86
|
-
* @param {Object} options - Options for checking
|
|
87
|
-
* @param {Function} options.resolveFunction - Custom resolve function for testing
|
|
88
|
-
* @param {Function} options.loadPackageJsonFn - Custom package.json loader for testing
|
|
89
|
-
* @returns {boolean} True if plugin is ready for use
|
|
90
|
-
*/
|
|
91
|
-
function isMCPPluginReady(options = {}) {
|
|
92
|
-
const { resolveFunction, loadPackageJsonFn } = options;
|
|
93
|
-
|
|
94
|
-
// Both conditions must be satisfied: declared in package.json AND available at runtime
|
|
95
|
-
return isMCPPluginInPackageJson(loadPackageJsonFn) && isMCPPluginAvailable(resolveFunction);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Build MCP server definition
|
|
100
|
-
* @param {Array} services - Array of CDS services
|
|
101
|
-
* @returns {Promise<Object>} MCP server definition
|
|
102
|
-
* @throws {Error} If MCP plugin is not available
|
|
103
|
-
*/
|
|
104
|
-
async function buildMcpServerDefinition(services = []) {
|
|
105
|
-
const plugin = getMcpPlugin();
|
|
106
|
-
|
|
107
|
-
if (!plugin) {
|
|
108
|
-
throw new Error("MCP plugin not available");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Validate services parameter
|
|
112
|
-
if (!Array.isArray(services)) {
|
|
113
|
-
throw new Error("Services parameter must be an array");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
const { exposeMcpServerDefinitionForOrd } = require("@btp-ai/mcp-plugin/lib/utils/metadata");
|
|
118
|
-
return await exposeMcpServerDefinitionForOrd(services);
|
|
119
|
-
} catch (error) {
|
|
120
|
-
Logger.error("Failed to build MCP server definition:", error.message);
|
|
121
|
-
throw error;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
module.exports = {
|
|
126
|
-
isMCPPluginAvailable,
|
|
127
|
-
isMCPPluginInPackageJson,
|
|
128
|
-
isMCPPluginReady,
|
|
129
|
-
getMcpPlugin,
|
|
130
|
-
buildMcpServerDefinition,
|
|
131
|
-
_loadPackageJson, // Export for testing
|
|
132
|
-
};
|