@gavdi/cap-mcp 1.1.4 → 1.2.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.
@@ -288,10 +288,12 @@ function parseCdsRestrictions(restrictions, requires) {
288
288
  });
289
289
  continue;
290
290
  }
291
- const mapped = el.to.map((to) => ({
292
- role: to,
293
- operations: ops,
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
- result.push("CREATE");
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
- switch (el) {
313
- case "CHANGE":
314
- result.push("UPDATE");
315
- continue;
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", "READ", "UPDATE", "DELETE"];
326
+ case "*":
327
+ return ["CREATE", "READ", "UPDATE", "DELETE"];
328
+ default:
329
+ return [restrictionType];
330
+ }
331
+ }
@@ -47,7 +47,7 @@ function authHandlerFactory() {
47
47
  return;
48
48
  }
49
49
  // For XSUAA/JWT auth types, use @sap/xssec for validation
50
- if ((authKind === "jwt" || authKind === "xsuaa") &&
50
+ if ((authKind === "jwt" || authKind === "xsuaa" || authKind === "ias") &&
51
51
  xsuaaService.isConfigured()) {
52
52
  const securityContext = await xsuaaService.createSecurityContext(req);
53
53
  if (!securityContext) {
@@ -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(tokenData);
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
@@ -191,7 +191,7 @@ function configureOAuthProxy(expressApp) {
191
191
  !credentials.url) {
192
192
  throw new Error("Invalid security credentials");
193
193
  }
194
- registerOAuthEndpoints(expressApp, credentials);
194
+ registerOAuthEndpoints(expressApp, credentials, kind);
195
195
  }
196
196
  /**
197
197
  * Determines the correct protocol (HTTP/HTTPS) for URL construction.
@@ -213,8 +213,10 @@ function getProtocol(req) {
213
213
  * Registers OAuth endpoints for XSUAA integration
214
214
  * Only called for jwt/xsuaa/ias auth types with valid credentials
215
215
  */
216
- function registerOAuthEndpoints(expressApp, credentials) {
216
+ function registerOAuthEndpoints(expressApp, credentials, kind) {
217
217
  const xsuaaService = new xsuaa_service_1.XSUAAService();
218
+ // Fetch endpoints from OIDC configuration
219
+ xsuaaService.discoverOAuthEndpoints();
218
220
  // Add JSON and URL-encoded body parsing for OAuth endpoints
219
221
  expressApp.use("/oauth", express_1.default.json());
220
222
  expressApp.use("/oauth", express_1.default.urlencoded({ extended: true }));
@@ -231,17 +233,17 @@ function registerOAuthEndpoints(expressApp, credentials) {
231
233
  }));
232
234
  // OAuth Authorization endpoint - stateless redirect to XSUAA
233
235
  expressApp.get("/oauth/authorize", (req, res) => {
234
- const { state, redirect_uri } = req.query;
236
+ const { state, redirect_uri, client_id, code_challenge, code_challenge_method, scope, } = req.query;
235
237
  // Client validation and redirect URI validation is handled by XSUAA
236
238
  // We delegate all client management to XSUAA's built-in OAuth server
237
239
  const protocol = getProtocol(req);
238
240
  const redirectUri = redirect_uri || `${protocol}://${req.get("host")}/oauth/callback`;
239
- const authUrl = xsuaaService.getAuthorizationUrl(redirectUri, state);
241
+ const authUrl = xsuaaService.getAuthorizationUrl(redirectUri, client_id ?? "", state, code_challenge, code_challenge_method, scope);
240
242
  res.redirect(authUrl);
241
243
  });
242
244
  // OAuth Callback endpoint - stateless token exchange
243
245
  expressApp.get("/oauth/callback", async (req, res) => {
244
- const { code, state, error, error_description, redirect_uri } = req.query;
246
+ const { code, state, error, error_description, redirect_uri, code_verifier, } = req.query;
245
247
  logger_1.LOGGER.debug("[AUTH] Callback received", code, state);
246
248
  if (error) {
247
249
  res.status(400).json({
@@ -260,8 +262,10 @@ function registerOAuthEndpoints(expressApp, credentials) {
260
262
  try {
261
263
  const protocol = getProtocol(req);
262
264
  const url = redirect_uri || `${protocol}://${req.get("host")}/oauth/callback`;
263
- const tokenData = await xsuaaService.exchangeCodeForToken(code, url);
264
- res.json(tokenData);
265
+ const tokenData = await xsuaaService.exchangeCodeForToken(code, url, code_verifier);
266
+ const scopedToken = await xsuaaService.getApplicationScopes(tokenData);
267
+ logger_1.LOGGER.debug("Scopes in token:", scopedToken.scope);
268
+ res.json(scopedToken);
265
269
  }
266
270
  catch (error) {
267
271
  logger_1.LOGGER.error("OAuth callback error:", error);
@@ -288,13 +292,27 @@ function registerOAuthEndpoints(expressApp, credentials) {
288
292
  response_types_supported: ["code"],
289
293
  grant_types_supported: ["authorization_code", "refresh_token"],
290
294
  code_challenge_methods_supported: ["S256"],
291
- // scopes_supported: ["uaa.resource"],
295
+ scopes_supported: ["openid"],
292
296
  token_endpoint_auth_methods_supported: ["client_secret_post"],
293
297
  registration_endpoint_auth_methods_supported: ["client_secret_basic"],
294
298
  });
295
299
  });
296
300
  // OAuth Dynamic Client Registration discovery endpoint (GET)
297
301
  expressApp.get("/oauth/register", async (req, res) => {
302
+ // IAS does not support DCR so we will respond with the pre-configured client_id
303
+ if (kind === "ias") {
304
+ const protocol = getProtocol(req);
305
+ const enhancedResponse = {
306
+ client_id: credentials.clientid, // Add our CAP app's client ID
307
+ redirect_uris: req.body.redirect_uris || [
308
+ `${protocol}://${req.get("host")}/oauth/callback`,
309
+ ],
310
+ };
311
+ logger_1.LOGGER.debug("Provided static client_id during DCR registration process");
312
+ res.json(enhancedResponse);
313
+ return;
314
+ }
315
+ // Keep original implementation for XSUAA
298
316
  try {
299
317
  // Simple proxy for discovery - no CSRF needed
300
318
  const response = await fetch(`${credentials.url}/oauth/register`, {
@@ -324,6 +342,20 @@ function registerOAuthEndpoints(expressApp, credentials) {
324
342
  });
325
343
  // OAuth Dynamic Client Registration endpoint (POST) with CSRF handling
326
344
  expressApp.post("/oauth/register", async (req, res) => {
345
+ // IAS does not support DCR so we will respond with the pre-configured client_id
346
+ if (kind === "ias") {
347
+ const protocol = getProtocol(req);
348
+ const enhancedResponse = {
349
+ client_id: credentials.clientid, // Add our CAP app's client ID
350
+ redirect_uris: req.body.redirect_uris || [
351
+ `${protocol}://${req.get("host")}/oauth/callback`,
352
+ ],
353
+ };
354
+ logger_1.LOGGER.debug("Provided static client_id during DCR registration process");
355
+ res.json(enhancedResponse);
356
+ return;
357
+ }
358
+ // Keep original implementation for XSUAA
327
359
  try {
328
360
  // Step 1: Fetch CSRF token from XSUAA
329
361
  const csrfResponse = await fetch(`${credentials.url}/oauth/register`, {
@@ -361,7 +393,7 @@ function registerOAuthEndpoints(expressApp, credentials) {
361
393
  const enhancedResponse = {
362
394
  ...xsuaaData, // Keep all XSUAA fields
363
395
  client_id: credentials.clientid, // Add our CAP app's client ID
364
- client_secret: credentials.clientsecret, // CAP app's client secret
396
+ // client_secret: credentials.clientsecret, // CAP app's client secret
365
397
  redirect_uris: req.body.redirect_uris || [
366
398
  `${protocol}://${req.get("host")}/oauth/callback`,
367
399
  ], // Use client's redirect URIs
@@ -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
- this.credentials = cds.env.requires.auth.credentials;
50
- // Initialize XSUAA service from @sap/xssec
51
- this.xsuaaService = new xssec.XsuaaService(this.credentials);
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.credentials.url}/oauth/authorize?${params.toString()}`;
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(`${this.credentials.url}/oauth/token`, {
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(`${this.credentials.url}/oauth/token`, {
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",
@@ -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.debug("Session initialized with ID: ", sid);
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.4",
3
+ "version": "1.2.0",
4
4
  "description": "MCP Pluging for CAP",
5
5
  "keywords": [
6
6
  "MCP",
@@ -41,12 +41,13 @@
41
41
  "release": "release-it"
42
42
  },
43
43
  "peerDependencies": {
44
- "@sap/cds": "^9.4.3",
44
+ "@sap/cds": "^9",
45
45
  "express": "^4"
46
46
  },
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",