@gavdi/cap-mcp 1.1.5 → 1.2.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/annotations/parser.js +6 -2
- package/lib/annotations/structures.js +11 -1
- package/lib/annotations/utils.js +27 -24
- package/lib/auth/factory.js +6 -3
- package/lib/auth/handlers.js +5 -3
- package/lib/auth/utils.js +49 -9
- package/lib/auth/xsuaa-service.js +103 -15
- package/lib/mcp/entity-tools.js +4 -2
- package/lib/mcp/session-manager.js +1 -1
- package/lib/mcp.js +3 -0
- package/package.json +3 -1
|
@@ -125,13 +125,17 @@ function parseAnnotations(definition) {
|
|
|
125
125
|
function constructResourceAnnotation(serviceName, target, annotations, definition, model) {
|
|
126
126
|
if (!(0, utils_1.isValidResourceAnnotation)(annotations))
|
|
127
127
|
return undefined;
|
|
128
|
+
const entityTarget = `${serviceName}.${target}`;
|
|
128
129
|
const functionalities = (0, utils_1.determineResourceOptions)(annotations);
|
|
129
|
-
const foreignKeys = new Map(Object.entries(model.definitions?.[
|
|
130
|
+
const foreignKeys = new Map(Object.entries(model.definitions?.[entityTarget].elements ?? {})
|
|
130
131
|
.filter(([_, v]) => v["@odata.foreignKey4"] !== undefined)
|
|
131
132
|
.map(([k, v]) => [k, v["@odata.foreignKey4"]]));
|
|
133
|
+
const computedFields = new Set(Object.entries(model.definitions?.[entityTarget].elements ?? {})
|
|
134
|
+
.filter(([_, v]) => new Map(Object.entries(v).map(([key, value]) => [key.toLowerCase(), value])).get("@core.computed"))
|
|
135
|
+
.map(([k, _]) => k));
|
|
132
136
|
const { properties, resourceKeys } = (0, utils_1.parseResourceElements)(definition, model);
|
|
133
137
|
const restrictions = (0, utils_1.parseCdsRestrictions)(annotations.restrict, annotations.requires);
|
|
134
|
-
return new structures_1.McpResourceAnnotation(annotations.name, annotations.description, target, serviceName, functionalities, properties, resourceKeys, foreignKeys, annotations.wrap, restrictions);
|
|
138
|
+
return new structures_1.McpResourceAnnotation(annotations.name, annotations.description, target, serviceName, functionalities, properties, resourceKeys, foreignKeys, annotations.wrap, restrictions, computedFields);
|
|
135
139
|
}
|
|
136
140
|
/**
|
|
137
141
|
* Constructs a tool annotation from parsed annotation data
|
|
@@ -84,6 +84,8 @@ class McpResourceAnnotation extends McpAnnotation {
|
|
|
84
84
|
_wrap;
|
|
85
85
|
/** Map of foreign keys property -> associated entity */
|
|
86
86
|
_foreignKeys;
|
|
87
|
+
/** Set of computed field names */
|
|
88
|
+
_computedFields;
|
|
87
89
|
/**
|
|
88
90
|
* Creates a new MCP resource annotation
|
|
89
91
|
* @param name - Unique identifier for this resource
|
|
@@ -96,14 +98,16 @@ class McpResourceAnnotation extends McpAnnotation {
|
|
|
96
98
|
* @param foreignKeys - Map of foreign keys used by entity
|
|
97
99
|
* @param wrap - Wrap usage
|
|
98
100
|
* @param restrictions - Optional restrictions based on CDS roles
|
|
101
|
+
* @param computedFields - Optional set of fields that are computed and should be ignored in create scenarios
|
|
99
102
|
*/
|
|
100
|
-
constructor(name, description, target, serviceName, functionalities, properties, resourceKeys, foreignKeys, wrap, restrictions) {
|
|
103
|
+
constructor(name, description, target, serviceName, functionalities, properties, resourceKeys, foreignKeys, wrap, restrictions, computedFields) {
|
|
101
104
|
super(name, description, target, serviceName, restrictions ?? []);
|
|
102
105
|
this._functionalities = functionalities;
|
|
103
106
|
this._properties = properties;
|
|
104
107
|
this._resourceKeys = resourceKeys;
|
|
105
108
|
this._wrap = wrap;
|
|
106
109
|
this._foreignKeys = foreignKeys;
|
|
110
|
+
this._computedFields = computedFields;
|
|
107
111
|
}
|
|
108
112
|
/**
|
|
109
113
|
* Gets the set of enabled OData query functionalities
|
|
@@ -139,6 +143,12 @@ class McpResourceAnnotation extends McpAnnotation {
|
|
|
139
143
|
get wrap() {
|
|
140
144
|
return this._wrap;
|
|
141
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Gets the computed fields if any are available
|
|
148
|
+
*/
|
|
149
|
+
get computedFields() {
|
|
150
|
+
return this._computedFields;
|
|
151
|
+
}
|
|
142
152
|
}
|
|
143
153
|
exports.McpResourceAnnotation = McpResourceAnnotation;
|
|
144
154
|
/**
|
package/lib/annotations/utils.js
CHANGED
|
@@ -288,10 +288,12 @@ function parseCdsRestrictions(restrictions, requires) {
|
|
|
288
288
|
});
|
|
289
289
|
continue;
|
|
290
290
|
}
|
|
291
|
-
const mapped = el.to
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
291
|
+
const mapped = Array.isArray(el.to)
|
|
292
|
+
? el.to.map((to) => ({
|
|
293
|
+
role: to,
|
|
294
|
+
operations: ops,
|
|
295
|
+
}))
|
|
296
|
+
: [{ role: el.to, operations: ops }];
|
|
295
297
|
result.push(...mapped);
|
|
296
298
|
}
|
|
297
299
|
return result;
|
|
@@ -300,29 +302,30 @@ function parseCdsRestrictions(restrictions, requires) {
|
|
|
300
302
|
* Maps the "grant" property from CdsRestriction to McpRestriction
|
|
301
303
|
*/
|
|
302
304
|
function mapOperationRestriction(cdsRestrictions) {
|
|
303
|
-
const result = [];
|
|
304
305
|
if (!cdsRestrictions || cdsRestrictions.length <= 0) {
|
|
305
|
-
|
|
306
|
-
result.push("READ");
|
|
307
|
-
result.push("UPDATE");
|
|
308
|
-
result.push("DELETE");
|
|
309
|
-
return result;
|
|
306
|
+
return ["CREATE", "READ", "UPDATE", "DELETE"];
|
|
310
307
|
}
|
|
308
|
+
if (!Array.isArray(cdsRestrictions)) {
|
|
309
|
+
return translateOperationRestriction(cdsRestrictions);
|
|
310
|
+
}
|
|
311
|
+
const result = [];
|
|
311
312
|
for (const el of cdsRestrictions) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
case "*":
|
|
317
|
-
result.push("CREATE");
|
|
318
|
-
result.push("READ");
|
|
319
|
-
result.push("UPDATE");
|
|
320
|
-
result.push("DELETE");
|
|
321
|
-
continue;
|
|
322
|
-
default:
|
|
323
|
-
result.push(el);
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
313
|
+
const translated = translateOperationRestriction(el);
|
|
314
|
+
if (!translated || translated.length <= 0)
|
|
315
|
+
continue;
|
|
316
|
+
result.push(...translated);
|
|
326
317
|
}
|
|
327
318
|
return result;
|
|
328
319
|
}
|
|
320
|
+
function translateOperationRestriction(restrictionType) {
|
|
321
|
+
switch (restrictionType) {
|
|
322
|
+
case "CHANGE":
|
|
323
|
+
return ["UPDATE"];
|
|
324
|
+
case "WRITE":
|
|
325
|
+
return ["CREATE", "UPDATE", "DELETE"];
|
|
326
|
+
case "*":
|
|
327
|
+
return ["CREATE", "READ", "UPDATE", "DELETE"];
|
|
328
|
+
default:
|
|
329
|
+
return [restrictionType];
|
|
330
|
+
}
|
|
331
|
+
}
|
package/lib/auth/factory.js
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.authHandlerFactory = authHandlerFactory;
|
|
4
4
|
exports.errorHandlerFactory = errorHandlerFactory;
|
|
5
5
|
const xsuaa_service_1 = require("./xsuaa-service");
|
|
6
|
+
const utils_1 = require("./utils");
|
|
7
|
+
const logger_1 = require("../logger");
|
|
6
8
|
/** JSON-RPC 2.0 error code for unauthorized requests */
|
|
7
9
|
const RPC_UNAUTHORIZED = 10;
|
|
8
10
|
/* @ts-ignore */
|
|
@@ -33,7 +35,8 @@ const cds = global.cds || require("@sap/cds"); // This is a work around for miss
|
|
|
33
35
|
*/
|
|
34
36
|
function authHandlerFactory() {
|
|
35
37
|
const authKind = cds.env.requires.auth.kind;
|
|
36
|
-
const xsuaaService = new xsuaa_service_1.XSUAAService();
|
|
38
|
+
const xsuaaService = !(0, utils_1.useMockAuth)(authKind) ? new xsuaa_service_1.XSUAAService() : undefined;
|
|
39
|
+
logger_1.LOGGER.debug("Authentication kind", authKind);
|
|
37
40
|
return async (req, res, next) => {
|
|
38
41
|
if (!req.headers.authorization && authKind !== "dummy") {
|
|
39
42
|
res.status(401).json({
|
|
@@ -47,8 +50,8 @@ function authHandlerFactory() {
|
|
|
47
50
|
return;
|
|
48
51
|
}
|
|
49
52
|
// For XSUAA/JWT auth types, use @sap/xssec for validation
|
|
50
|
-
if ((authKind === "jwt" || authKind === "xsuaa") &&
|
|
51
|
-
xsuaaService
|
|
53
|
+
if ((authKind === "jwt" || authKind === "xsuaa" || authKind === "ias") &&
|
|
54
|
+
xsuaaService?.isConfigured()) {
|
|
52
55
|
const securityContext = await xsuaaService.createSecurityContext(req);
|
|
53
56
|
if (!securityContext) {
|
|
54
57
|
res.status(401).json({
|
package/lib/auth/handlers.js
CHANGED
|
@@ -10,7 +10,7 @@ const logger_1 = require("../logger");
|
|
|
10
10
|
async function handleTokenRequest(req, res, xsuaaService) {
|
|
11
11
|
try {
|
|
12
12
|
const params = { ...req.query, ...req.body };
|
|
13
|
-
const { grant_type, code, redirect_uri, refresh_token } = params;
|
|
13
|
+
const { grant_type, code, redirect_uri, refresh_token, code_verifier } = params;
|
|
14
14
|
if (grant_type === "authorization_code") {
|
|
15
15
|
if (!code || !redirect_uri) {
|
|
16
16
|
res.status(400).json({
|
|
@@ -19,9 +19,11 @@ async function handleTokenRequest(req, res, xsuaaService) {
|
|
|
19
19
|
});
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
|
-
const tokenData = await xsuaaService.exchangeCodeForToken(code, redirect_uri);
|
|
22
|
+
const tokenData = await xsuaaService.exchangeCodeForToken(code, redirect_uri, code_verifier);
|
|
23
|
+
const scopedToken = await xsuaaService.getApplicationScopes(tokenData);
|
|
24
|
+
logger_1.LOGGER.debug("Scopes in token:", scopedToken.scope);
|
|
23
25
|
logger_1.LOGGER.debug("[AUTH] Token exchange successful");
|
|
24
|
-
res.json(
|
|
26
|
+
res.json(scopedToken);
|
|
25
27
|
}
|
|
26
28
|
else if (grant_type === "refresh_token") {
|
|
27
29
|
if (!refresh_token) {
|
package/lib/auth/utils.js
CHANGED
|
@@ -8,6 +8,7 @@ exports.getAccessRights = getAccessRights;
|
|
|
8
8
|
exports.registerAuthMiddleware = registerAuthMiddleware;
|
|
9
9
|
exports.hasToolOperationAccess = hasToolOperationAccess;
|
|
10
10
|
exports.getWrapAccesses = getWrapAccesses;
|
|
11
|
+
exports.useMockAuth = useMockAuth;
|
|
11
12
|
const express_1 = __importDefault(require("express"));
|
|
12
13
|
const helmet_1 = __importDefault(require("helmet"));
|
|
13
14
|
const factory_1 = require("./factory");
|
|
@@ -191,7 +192,7 @@ function configureOAuthProxy(expressApp) {
|
|
|
191
192
|
!credentials.url) {
|
|
192
193
|
throw new Error("Invalid security credentials");
|
|
193
194
|
}
|
|
194
|
-
registerOAuthEndpoints(expressApp, credentials);
|
|
195
|
+
registerOAuthEndpoints(expressApp, credentials, kind);
|
|
195
196
|
}
|
|
196
197
|
/**
|
|
197
198
|
* Determines the correct protocol (HTTP/HTTPS) for URL construction.
|
|
@@ -213,8 +214,10 @@ function getProtocol(req) {
|
|
|
213
214
|
* Registers OAuth endpoints for XSUAA integration
|
|
214
215
|
* Only called for jwt/xsuaa/ias auth types with valid credentials
|
|
215
216
|
*/
|
|
216
|
-
function registerOAuthEndpoints(expressApp, credentials) {
|
|
217
|
+
function registerOAuthEndpoints(expressApp, credentials, kind) {
|
|
217
218
|
const xsuaaService = new xsuaa_service_1.XSUAAService();
|
|
219
|
+
// Fetch endpoints from OIDC configuration
|
|
220
|
+
xsuaaService.discoverOAuthEndpoints();
|
|
218
221
|
// Add JSON and URL-encoded body parsing for OAuth endpoints
|
|
219
222
|
expressApp.use("/oauth", express_1.default.json());
|
|
220
223
|
expressApp.use("/oauth", express_1.default.urlencoded({ extended: true }));
|
|
@@ -231,17 +234,17 @@ function registerOAuthEndpoints(expressApp, credentials) {
|
|
|
231
234
|
}));
|
|
232
235
|
// OAuth Authorization endpoint - stateless redirect to XSUAA
|
|
233
236
|
expressApp.get("/oauth/authorize", (req, res) => {
|
|
234
|
-
const { state, redirect_uri } = req.query;
|
|
237
|
+
const { state, redirect_uri, client_id, code_challenge, code_challenge_method, scope, } = req.query;
|
|
235
238
|
// Client validation and redirect URI validation is handled by XSUAA
|
|
236
239
|
// We delegate all client management to XSUAA's built-in OAuth server
|
|
237
240
|
const protocol = getProtocol(req);
|
|
238
241
|
const redirectUri = redirect_uri || `${protocol}://${req.get("host")}/oauth/callback`;
|
|
239
|
-
const authUrl = xsuaaService.getAuthorizationUrl(redirectUri, state);
|
|
242
|
+
const authUrl = xsuaaService.getAuthorizationUrl(redirectUri, client_id ?? "", state, code_challenge, code_challenge_method, scope);
|
|
240
243
|
res.redirect(authUrl);
|
|
241
244
|
});
|
|
242
245
|
// OAuth Callback endpoint - stateless token exchange
|
|
243
246
|
expressApp.get("/oauth/callback", async (req, res) => {
|
|
244
|
-
const { code, state, error, error_description, redirect_uri } = req.query;
|
|
247
|
+
const { code, state, error, error_description, redirect_uri, code_verifier, } = req.query;
|
|
245
248
|
logger_1.LOGGER.debug("[AUTH] Callback received", code, state);
|
|
246
249
|
if (error) {
|
|
247
250
|
res.status(400).json({
|
|
@@ -260,8 +263,10 @@ function registerOAuthEndpoints(expressApp, credentials) {
|
|
|
260
263
|
try {
|
|
261
264
|
const protocol = getProtocol(req);
|
|
262
265
|
const url = redirect_uri || `${protocol}://${req.get("host")}/oauth/callback`;
|
|
263
|
-
const tokenData = await xsuaaService.exchangeCodeForToken(code, url);
|
|
264
|
-
|
|
266
|
+
const tokenData = await xsuaaService.exchangeCodeForToken(code, url, code_verifier);
|
|
267
|
+
const scopedToken = await xsuaaService.getApplicationScopes(tokenData);
|
|
268
|
+
logger_1.LOGGER.debug("Scopes in token:", scopedToken.scope);
|
|
269
|
+
res.json(scopedToken);
|
|
265
270
|
}
|
|
266
271
|
catch (error) {
|
|
267
272
|
logger_1.LOGGER.error("OAuth callback error:", error);
|
|
@@ -288,13 +293,27 @@ function registerOAuthEndpoints(expressApp, credentials) {
|
|
|
288
293
|
response_types_supported: ["code"],
|
|
289
294
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
290
295
|
code_challenge_methods_supported: ["S256"],
|
|
291
|
-
|
|
296
|
+
scopes_supported: ["openid"],
|
|
292
297
|
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
|
293
298
|
registration_endpoint_auth_methods_supported: ["client_secret_basic"],
|
|
294
299
|
});
|
|
295
300
|
});
|
|
296
301
|
// OAuth Dynamic Client Registration discovery endpoint (GET)
|
|
297
302
|
expressApp.get("/oauth/register", async (req, res) => {
|
|
303
|
+
// IAS does not support DCR so we will respond with the pre-configured client_id
|
|
304
|
+
if (kind === "ias") {
|
|
305
|
+
const protocol = getProtocol(req);
|
|
306
|
+
const enhancedResponse = {
|
|
307
|
+
client_id: credentials.clientid, // Add our CAP app's client ID
|
|
308
|
+
redirect_uris: req.body.redirect_uris || [
|
|
309
|
+
`${protocol}://${req.get("host")}/oauth/callback`,
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
logger_1.LOGGER.debug("Provided static client_id during DCR registration process");
|
|
313
|
+
res.json(enhancedResponse);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Keep original implementation for XSUAA
|
|
298
317
|
try {
|
|
299
318
|
// Simple proxy for discovery - no CSRF needed
|
|
300
319
|
const response = await fetch(`${credentials.url}/oauth/register`, {
|
|
@@ -324,6 +343,20 @@ function registerOAuthEndpoints(expressApp, credentials) {
|
|
|
324
343
|
});
|
|
325
344
|
// OAuth Dynamic Client Registration endpoint (POST) with CSRF handling
|
|
326
345
|
expressApp.post("/oauth/register", async (req, res) => {
|
|
346
|
+
// IAS does not support DCR so we will respond with the pre-configured client_id
|
|
347
|
+
if (kind === "ias") {
|
|
348
|
+
const protocol = getProtocol(req);
|
|
349
|
+
const enhancedResponse = {
|
|
350
|
+
client_id: credentials.clientid, // Add our CAP app's client ID
|
|
351
|
+
redirect_uris: req.body.redirect_uris || [
|
|
352
|
+
`${protocol}://${req.get("host")}/oauth/callback`,
|
|
353
|
+
],
|
|
354
|
+
};
|
|
355
|
+
logger_1.LOGGER.debug("Provided static client_id during DCR registration process");
|
|
356
|
+
res.json(enhancedResponse);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// Keep original implementation for XSUAA
|
|
327
360
|
try {
|
|
328
361
|
// Step 1: Fetch CSRF token from XSUAA
|
|
329
362
|
const csrfResponse = await fetch(`${credentials.url}/oauth/register`, {
|
|
@@ -361,7 +394,7 @@ function registerOAuthEndpoints(expressApp, credentials) {
|
|
|
361
394
|
const enhancedResponse = {
|
|
362
395
|
...xsuaaData, // Keep all XSUAA fields
|
|
363
396
|
client_id: credentials.clientid, // Add our CAP app's client ID
|
|
364
|
-
client_secret: credentials.clientsecret, // CAP app's client secret
|
|
397
|
+
// client_secret: credentials.clientsecret, // CAP app's client secret
|
|
365
398
|
redirect_uris: req.body.redirect_uris || [
|
|
366
399
|
`${protocol}://${req.get("host")}/oauth/callback`,
|
|
367
400
|
], // Use client's redirect URIs
|
|
@@ -448,3 +481,10 @@ function getWrapAccesses(user, restrictions) {
|
|
|
448
481
|
}
|
|
449
482
|
return access;
|
|
450
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* Utility method for checking whether auth used is mocked and not live
|
|
486
|
+
* @returns boolean
|
|
487
|
+
*/
|
|
488
|
+
function useMockAuth(authKind) {
|
|
489
|
+
return authKind !== "jwt" && authKind !== "ias" && authKind !== "xsuaa";
|
|
490
|
+
}
|
|
@@ -45,47 +45,104 @@ const cds = global.cds || require("@sap/cds");
|
|
|
45
45
|
class XSUAAService {
|
|
46
46
|
credentials;
|
|
47
47
|
xsuaaService;
|
|
48
|
+
endpoints;
|
|
48
49
|
constructor() {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// In case of IAS, the final token conversion to get application scopes has to be done with XSUAA, so there will be 2 sets of credentials
|
|
51
|
+
this.credentials = {
|
|
52
|
+
authProvider: cds.env.requires.auth?.credentials,
|
|
53
|
+
xsuaa: cds.env.requires.xsuaa?.credentials ||
|
|
54
|
+
cds.env.requires.auth?.credentials,
|
|
55
|
+
};
|
|
56
|
+
// Set default endpoints in case OIDC discovery call fails
|
|
57
|
+
this.endpoints = {
|
|
58
|
+
authProvider: {
|
|
59
|
+
discovery_url: `${this.credentials.authProvider?.url}/.well-known/openid-configuration`,
|
|
60
|
+
authorization_endpoint: `${this.credentials.authProvider?.url}/oauth/authorize`,
|
|
61
|
+
token_endpoint: `${this.credentials.authProvider?.url}/oauth/token`,
|
|
62
|
+
},
|
|
63
|
+
xsuaa: {
|
|
64
|
+
discovery_url: `${this.credentials.xsuaa?.url}/.well-known/openid-configuration`,
|
|
65
|
+
authorization_endpoint: `${this.credentials.xsuaa?.url}/oauth/authorize`,
|
|
66
|
+
token_endpoint: `${this.credentials.xsuaa?.url}/oauth/token`,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
this.xsuaaService = new xssec.XsuaaService(this.credentials.xsuaa);
|
|
52
70
|
}
|
|
53
71
|
isConfigured() {
|
|
54
|
-
return !!(this.credentials?.clientid &&
|
|
55
|
-
this.credentials?.clientsecret &&
|
|
56
|
-
this.credentials?.url);
|
|
72
|
+
return !!(this.credentials.authProvider?.clientid &&
|
|
73
|
+
this.credentials.authProvider?.clientsecret &&
|
|
74
|
+
this.credentials.authProvider?.url);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Fetch oauth endpoints from the OIDC discovery endpoints from both XSUAA (and IAS).
|
|
78
|
+
* If none found than the default will be used.
|
|
79
|
+
*/
|
|
80
|
+
async discoverOAuthEndpoints() {
|
|
81
|
+
// Do discovery for both 'authProvider' and 'xsuaa' endpoint sets
|
|
82
|
+
try {
|
|
83
|
+
const endpointKeys = Object.keys(this.endpoints);
|
|
84
|
+
for (let key of endpointKeys) {
|
|
85
|
+
const response = await fetch(this.endpoints[key].discovery_url, {
|
|
86
|
+
method: "GET",
|
|
87
|
+
headers: {
|
|
88
|
+
Accept: "application/json",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const errorData = await response.json();
|
|
93
|
+
logger_1.LOGGER.warn(`OAuth endpoints fetch failed: ${response.status} ${errorData.error_description || errorData.error}. Continuing with default configuration.`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const oidcConfig = await response.json();
|
|
97
|
+
this.endpoints[key].authorization_endpoint =
|
|
98
|
+
oidcConfig.authorization_endpoint;
|
|
99
|
+
this.endpoints[key].token_endpoint = oidcConfig.token_endpoint;
|
|
100
|
+
logger_1.LOGGER.debug(`OAuth endpoints for [${key}] set to:`, this.endpoints[key]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error instanceof Error) {
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`OAuth endpoints fetch failed: ${String(error)}`);
|
|
109
|
+
}
|
|
57
110
|
}
|
|
58
111
|
/**
|
|
59
112
|
* Generates authorization URL using @sap/xssec
|
|
60
113
|
*/
|
|
61
|
-
getAuthorizationUrl(redirectUri, state) {
|
|
114
|
+
getAuthorizationUrl(redirectUri, client_id, state, code_challenge, code_challenge_method, scope) {
|
|
62
115
|
const params = new URLSearchParams({
|
|
63
116
|
response_type: "code",
|
|
64
|
-
client_id: this.credentials.clientid,
|
|
65
117
|
redirect_uri: redirectUri,
|
|
66
118
|
// scope: "uaa.resource",
|
|
119
|
+
client_id,
|
|
120
|
+
...(!!code_challenge ? { code_challenge } : {}),
|
|
121
|
+
...(!!code_challenge_method ? { code_challenge_method } : {}),
|
|
122
|
+
...(!!scope ? { scope } : {}),
|
|
67
123
|
});
|
|
68
124
|
if (state) {
|
|
69
125
|
params.append("state", state);
|
|
70
126
|
}
|
|
71
|
-
return `${this.
|
|
127
|
+
return `${this.endpoints.authProvider.authorization_endpoint}?${params.toString()}`;
|
|
72
128
|
}
|
|
73
129
|
/**
|
|
74
130
|
* Exchange authorization code for token using @sap/xssec
|
|
75
131
|
*/
|
|
76
|
-
async exchangeCodeForToken(code, redirectUri) {
|
|
132
|
+
async exchangeCodeForToken(code, redirectUri, code_verifier) {
|
|
77
133
|
try {
|
|
78
134
|
const tokenOptions = {
|
|
79
135
|
grant_type: "authorization_code",
|
|
80
136
|
code,
|
|
81
137
|
redirect_uri: redirectUri,
|
|
138
|
+
...(!!code_verifier ? { code_verifier } : {}),
|
|
82
139
|
};
|
|
83
|
-
// Use direct XSUAA token endpoint for authorization code exchange
|
|
84
|
-
const response = await fetch(
|
|
140
|
+
// Use direct XSUAA/IAS token endpoint for authorization code exchange
|
|
141
|
+
const response = await fetch(this.endpoints.authProvider.token_endpoint, {
|
|
85
142
|
method: "POST",
|
|
86
143
|
headers: {
|
|
87
144
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
88
|
-
Authorization: `Basic ${Buffer.from(`${this.credentials.clientid}:${this.credentials.clientsecret}`).toString("base64")}`,
|
|
145
|
+
Authorization: `Basic ${Buffer.from(`${this.credentials.authProvider?.clientid}:${this.credentials.authProvider?.clientsecret}`).toString("base64")}`,
|
|
89
146
|
},
|
|
90
147
|
body: new URLSearchParams(tokenOptions),
|
|
91
148
|
});
|
|
@@ -102,16 +159,47 @@ class XSUAAService {
|
|
|
102
159
|
throw new Error(`Token exchange failed: ${String(error)}`);
|
|
103
160
|
}
|
|
104
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Convert access_token from authorization code into access_token with application scopes
|
|
164
|
+
*/
|
|
165
|
+
async getApplicationScopes(token) {
|
|
166
|
+
try {
|
|
167
|
+
const tokenOptions = {
|
|
168
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
169
|
+
reponse_type: "token+id_token",
|
|
170
|
+
assertion: token.access_token,
|
|
171
|
+
};
|
|
172
|
+
const response = await fetch(this.endpoints.xsuaa.token_endpoint, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: {
|
|
175
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
176
|
+
Authorization: `Basic ${Buffer.from(`${this.credentials.xsuaa?.clientid}:${this.credentials.xsuaa?.clientsecret}`).toString("base64")}`,
|
|
177
|
+
},
|
|
178
|
+
body: new URLSearchParams(tokenOptions),
|
|
179
|
+
});
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
const errorData = (await response.json());
|
|
182
|
+
throw new Error(`Token exchange for scopes failed: ${response.status} ${errorData.error_description || errorData.error}`);
|
|
183
|
+
}
|
|
184
|
+
return response.json();
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error instanceof Error) {
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`Token exchange for scopes failed: ${String(error)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
105
193
|
/**
|
|
106
194
|
* Refresh access token using @sap/xssec
|
|
107
195
|
*/
|
|
108
196
|
async refreshAccessToken(refreshToken) {
|
|
109
197
|
try {
|
|
110
|
-
const response = await fetch(
|
|
198
|
+
const response = await fetch(this.endpoints.xsuaa.token_endpoint, {
|
|
111
199
|
method: "POST",
|
|
112
200
|
headers: {
|
|
113
201
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
114
|
-
Authorization: `Basic ${Buffer.from(`${this.credentials.clientid}:${this.credentials.clientsecret}`).toString("base64")}`,
|
|
202
|
+
Authorization: `Basic ${Buffer.from(`${this.credentials.xsuaa?.clientid}:${this.credentials.xsuaa?.clientsecret}`).toString("base64")}`,
|
|
115
203
|
},
|
|
116
204
|
body: new URLSearchParams({
|
|
117
205
|
grant_type: "refresh_token",
|
package/lib/mcp/entity-tools.js
CHANGED
|
@@ -344,7 +344,8 @@ function registerCreateTool(resAnno, server, authEnabled) {
|
|
|
344
344
|
const inputSchema = {};
|
|
345
345
|
for (const [propName, cdsType] of resAnno.properties.entries()) {
|
|
346
346
|
const isAssociation = String(cdsType).toLowerCase().includes("association");
|
|
347
|
-
|
|
347
|
+
const isComputed = resAnno.computedFields?.has(propName);
|
|
348
|
+
if (isAssociation || isComputed) {
|
|
348
349
|
// Association keys are supplied directly from model loading as of v1.1.2
|
|
349
350
|
continue;
|
|
350
351
|
}
|
|
@@ -431,8 +432,9 @@ function registerUpdateTool(resAnno, server, authEnabled) {
|
|
|
431
432
|
for (const [propName, cdsType] of resAnno.properties.entries()) {
|
|
432
433
|
if (resAnno.resourceKeys.has(propName))
|
|
433
434
|
continue;
|
|
435
|
+
const isComputed = resAnno.computedFields?.has(propName);
|
|
434
436
|
const isAssociation = String(cdsType).toLowerCase().includes("association");
|
|
435
|
-
if (isAssociation) {
|
|
437
|
+
if (isAssociation || isComputed) {
|
|
436
438
|
// Association keys are supplied directly from model loading as of v1.1.2
|
|
437
439
|
continue;
|
|
438
440
|
}
|
|
@@ -70,7 +70,7 @@ class McpSessionManager {
|
|
|
70
70
|
sessionIdGenerator: () => (0, crypto_1.randomUUID)(),
|
|
71
71
|
enableJsonResponse: enableJson,
|
|
72
72
|
onsessioninitialized: (sid) => {
|
|
73
|
-
logger_1.LOGGER.
|
|
73
|
+
logger_1.LOGGER.info("Session initialized with ID: ", sid);
|
|
74
74
|
logger_1.LOGGER.debug("Transport mode", { enableJsonResponse: enableJson });
|
|
75
75
|
this.sessions.set(sid, {
|
|
76
76
|
server: server,
|
package/lib/mcp.js
CHANGED
|
@@ -13,6 +13,7 @@ const loader_1 = require("./config/loader");
|
|
|
13
13
|
const session_manager_1 = require("./mcp/session-manager");
|
|
14
14
|
const utils_2 = require("./auth/utils");
|
|
15
15
|
const helmet_1 = __importDefault(require("helmet"));
|
|
16
|
+
const cors_1 = __importDefault(require("cors"));
|
|
16
17
|
/* @ts-ignore */
|
|
17
18
|
const cds = global.cds; // Use hosting app's CDS instance exclusively
|
|
18
19
|
/**
|
|
@@ -41,6 +42,8 @@ class McpPlugin {
|
|
|
41
42
|
logger_1.LOGGER.debug("Event received for 'bootstrap'");
|
|
42
43
|
this.expressApp = app;
|
|
43
44
|
this.expressApp.use("/mcp", express_1.default.json());
|
|
45
|
+
// Only needed to use MCP Inspector in local browser:
|
|
46
|
+
this.expressApp.use(["/oauth", "/.well-known"], (0, cors_1.default)({ origin: "http://localhost:6274" }));
|
|
44
47
|
// Apply helmet security middleware only to MCP routes
|
|
45
48
|
this.expressApp.use("/mcp", (0, helmet_1.default)({
|
|
46
49
|
contentSecurityPolicy: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavdi/cap-mcp",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "MCP Pluging for CAP",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"MCP",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.19.1",
|
|
49
49
|
"@sap/xssec": "^4.9.1",
|
|
50
|
+
"cors": "^2.8.5",
|
|
50
51
|
"helmet": "^8.1.0",
|
|
51
52
|
"zod": "^3.25.67",
|
|
52
53
|
"zod-to-json-schema": "^3.24.5"
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"devDependencies": {
|
|
55
56
|
"@cap-js/cds-types": "^0.13.0",
|
|
56
57
|
"@release-it/conventional-changelog": "^10.0.1",
|
|
58
|
+
"@types/cors": "^2.8.19",
|
|
57
59
|
"@types/express": "^5.0.3",
|
|
58
60
|
"@types/jest": "^30.0.0",
|
|
59
61
|
"@types/node": "^24.0.3",
|