@gavdi/cap-mcp 0.9.9-alpha.3 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -550,24 +550,7 @@ npm test -- --testPathPattern=integration
550
550
  ## 🚨 Performance & Limitations
551
551
 
552
552
  ### Known Limitations
553
-
554
- #### No Interactive Authentication Support
555
- **The plugin currently does NOT support interactive OAuth flows** that allow end-users to log in through MCP clients like Claude Desktop, Cursor, or other consumer MCP applications.
556
-
557
- **What this means:**
558
- - ✅ Works with custom MCP clients that can inject pre-obtained bearer tokens
559
- - ✅ Works in development with `dummy` authentication
560
- - ❌ **Does NOT work with Claude Desktop, Cursor, or similar clients expecting OAuth login flows**
561
- - ❌ End-users cannot authenticate interactively when connecting
562
-
563
- **Technical Context:** This limitation exists due to architectural constraints in the current Model Context Protocol SDK. The MCP community is actively working on a solution that would enable proper interactive authentication flows, but no timeline has been announced. This is expected to be resolved in the second half of 2025.
564
-
565
- **Workarounds:**
566
- - **For Development**: Use `"auth": { "kind": "dummy" }` in your CAP configuration
567
- - **For Production**: Custom MCP clients must obtain valid bearer tokens through your CAP application's existing authentication flow and include them in requests as `Authorization: Bearer <token>`
568
-
569
- #### SDK Bug
570
- - **Dynamic Resource Queries**: Require all query parameters due to `@modelcontextprotocol/sdk` RFC template string issue
553
+ - **SDK Bug**: Dynamic resource queries require all query parameters due to `@modelcontextprotocol/sdk` RFC template string issue
571
554
 
572
555
  ### Performance Considerations
573
556
  - **Large Datasets**: Use `resource: ['top']` or similar constraints for entities with many records
@@ -2,11 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.authHandlerFactory = authHandlerFactory;
4
4
  exports.errorHandlerFactory = errorHandlerFactory;
5
- const logger_1 = require("../logger");
6
5
  /** JSON-RPC 2.0 error code for unauthorized requests */
7
6
  const RPC_UNAUTHORIZED = 10;
8
- /** HTTP Authenticate header **/
9
- const WWW_AUTHENTICATE = "WWW-Authenticate";
10
7
  /* @ts-ignore */
11
8
  const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
12
9
  /**
@@ -34,15 +31,9 @@ const cds = global.cds || require("@sap/cds"); // This is a work around for miss
34
31
  * @throws {500} When CAP context is not properly loaded
35
32
  */
36
33
  function authHandlerFactory() {
34
+ const authKind = cds.env.requires.auth.kind;
37
35
  return (req, res, next) => {
38
- const auth = cds.env.requires.auth;
39
- const authKind = auth.kind;
40
- const credentials = auth.credentials;
41
36
  if (!req.headers.authorization && authKind !== "dummy") {
42
- logger_1.LOGGER.warn("No valid authorization header provided");
43
- // We need to return a WWW-Authenticate response header here with .well-known metadata
44
- // Otherwise the MCP client will not be able to figure out the auth flow
45
- res.setHeader(WWW_AUTHENTICATE, `Bearer error='"invalid_token", resource_metadata="${credentials?.url}/.well-known/oauth-protected-resource"`);
46
37
  res.status(401).json({
47
38
  jsonrpc: "2.0",
48
39
  error: {
@@ -67,9 +58,6 @@ function authHandlerFactory() {
67
58
  }
68
59
  const user = ctx.user;
69
60
  if (!user || user === cds.User.anonymous) {
70
- // We need to return a WWW-Authenticate response header here with .well-known metadata
71
- // Otherwise the MCP client will not be able to figure out the auth flow
72
- res.setHeader(WWW_AUTHENTICATE, `Bearer error='"invalid_token", resource_metadata="${credentials?.url}/.well-known/oauth-protected-resource"`);
73
61
  res.status(401).json({
74
62
  jsonrpc: "2.0",
75
63
  error: {
package/lib/auth/utils.js CHANGED
@@ -6,10 +6,30 @@ exports.registerAuthMiddleware = registerAuthMiddleware;
6
6
  exports.hasToolOperationAccess = hasToolOperationAccess;
7
7
  exports.getWrapAccesses = getWrapAccesses;
8
8
  const handler_1 = require("./handler");
9
+ const proxyProvider_js_1 = require("@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js");
9
10
  const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js");
10
- const logger_1 = require("../logger");
11
+ /**
12
+ * @fileoverview Authentication utilities for MCP-CAP integration.
13
+ *
14
+ * This module provides utilities for integrating CAP authentication with MCP servers.
15
+ * It supports all standard CAP authentication types and provides functions for:
16
+ * - Determining authentication status
17
+ * - Managing user access rights
18
+ * - Registering authentication middleware
19
+ *
20
+ * Supported CAP authentication types:
21
+ * - 'dummy': No authentication (privileged access)
22
+ * - 'mocked': Mock users with predefined credentials
23
+ * - 'basic': HTTP Basic Authentication
24
+ * - 'jwt': Generic JWT token validation
25
+ * - 'xsuaa': SAP BTP XSUAA OAuth2/JWT authentication
26
+ * - 'ias': SAP Identity Authentication Service
27
+ * - Custom string types for user-defined authentication strategies
28
+ *
29
+ * Access CAP auth configuration via: cds.env.requires.auth.kind
30
+ */
11
31
  /* @ts-ignore */
12
- const cds = global.cds || require("@sap/cds"); // Use hosting app's CDS instance exclusively
32
+ const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
13
33
  /**
14
34
  * Determines whether authentication is enabled for the MCP plugin.
15
35
  *
@@ -97,8 +117,7 @@ function getAccessRights(authEnabled) {
97
117
  * @since 1.0.0
98
118
  */
99
119
  function registerAuthMiddleware(expressApp) {
100
- logger_1.LOGGER.debug("Configuring auth middleware");
101
- const middlewares = cds.middlewares?.before || []; // Handle missing middlewares gracefully
120
+ const middlewares = cds.middlewares.before; // No types exists for this part of the CDS library
102
121
  // Build array of auth middleware to apply
103
122
  const authMiddleware = [];
104
123
  // Add CAP middleware
@@ -113,10 +132,8 @@ function registerAuthMiddleware(expressApp) {
113
132
  authMiddleware.push((0, handler_1.authHandlerFactory)());
114
133
  // Apply auth middleware to all /mcp routes EXCEPT health
115
134
  expressApp?.use(/^\/mcp(?!\/health).*/, ...authMiddleware);
116
- // Note: .well-known/oauth-authorization-server endpoint is automatically created by mcpAuthRouter
117
- // Configure OAuth proxy for enterprise authentication scenarios
135
+ // Then finally we add the oauth proxy to the xsuaa instance
118
136
  configureOAuthProxy(expressApp);
119
- logger_1.LOGGER.debug("Auth middleware configured");
120
137
  }
121
138
  /**
122
139
  * Configures OAuth proxy middleware for enterprise authentication scenarios.
@@ -129,7 +146,6 @@ function registerAuthMiddleware(expressApp) {
129
146
  * - Access token verification and validation
130
147
  * - Client credential management
131
148
  * - Integration with CAP authentication configuration
132
- * - Dynamic client registration for MCP clients
133
149
  *
134
150
  * The OAuth proxy is only configured for enterprise authentication types
135
151
  * (jwt, xsuaa, ias) and skips configuration for basic auth types.
@@ -159,86 +175,42 @@ function configureOAuthProxy(expressApp) {
159
175
  const config = cds.env.requires.auth;
160
176
  const kind = config.kind;
161
177
  const credentials = config.credentials;
162
- logger_1.LOGGER.debug("Running auth with configuration kind", kind);
163
178
  // Safety guard - skip OAuth proxy for basic auth types
164
- if (kind === "dummy" || kind === "mocked" || kind === "basic") {
165
- logger_1.LOGGER.debug("Skipping OAuth proxy for auth type:", kind);
179
+ if (kind === "dummy" || kind === "mocked" || kind === "basic")
166
180
  return;
167
- }
168
- if (!credentials ||
181
+ else if (!credentials ||
169
182
  !credentials.clientid ||
170
183
  !credentials.clientsecret ||
171
184
  !credentials.url) {
172
- logger_1.LOGGER.warn("OAuth proxy skipped - missing required XSUAA credentials");
173
- return; // Don't throw error, just skip OAuth proxy
185
+ throw new Error("Invalid security credentials");
174
186
  }
175
- logger_1.LOGGER.debug("Configuring OAuth proxy with XSUAA endpoints");
176
- const baseUrl = process.env.MCP_BASE_URL ||
177
- process.env.MCP_SERVER_URL ||
178
- "http://localhost:4004";
179
- // const proxyProvider = new ProxyOAuthServerProvider({
180
- // endpoints: {
181
- // authorizationUrl: `${credentials.url}/oauth/authorize`,
182
- // tokenUrl: `${credentials.url}/oauth/token`,
183
- // revocationUrl: `${credentials.url}/oauth/revoke`,
184
- // },
185
- // verifyAccessToken: async (token: string) => {
186
- // try {
187
- // LOGGER.debug("OAuth proxy: verifyAccessToken called");
188
- //
189
- // // Use CAP's built-in JWT verification for XSUAA
190
- // const decoded = await cds.auth.jwt.verify(token);
191
- // LOGGER.debug(
192
- // "Token decoded successfully for client:",
193
- // decoded.client_id || decoded.azp,
194
- // );
195
- //
196
- // return {
197
- // token,
198
- // clientId: decoded.client_id || decoded.azp,
199
- // scopes: decoded.scope?.split(" ") || [],
200
- // userId: decoded.sub,
201
- // expiresAt: decoded.exp, // Unix timestamp, not Date object
202
- // };
203
- // } catch (error) {
204
- // LOGGER.error("Token verification failed:", error);
205
- // throw new Error("Invalid access token");
206
- // }
207
- // },
208
- // getClient: async (client_id: string) => {
209
- // LOGGER.debug("OAuth proxy: Dynamic client registration requested");
210
- //
211
- // return {
212
- // client_secret: credentials.clientsecret as string,
213
- // client_id,
214
- // redirect_uris: [
215
- // `${baseUrl}/oauth/callback`,
216
- // `${baseUrl}/mcp/oauth/callback`,
217
- // "http://localhost:3000/callback", // Claude Desktop default
218
- // "http://localhost:3000/auth/callback", // Alternative format
219
- // ],
220
- // };
221
- // },
222
- // });
223
- expressApp.use((0, router_js_1.mcpAuthMetadataRouter)({
224
- oauthMetadata: {
225
- issuer: credentials.url,
226
- authorization_endpoint: `${credentials.url}/oauth/authorize`,
227
- token_endpoint: `${credentials.url}/oauth/token`,
228
- response_types_supported: ["code", "token"],
229
- grant_types_supported: [
230
- "authorization_code",
231
- "client_credentials",
232
- "urn:ietf:params:oauth:grant-type:jwt-bearer",
233
- "refresh_token"
234
- ],
235
- token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
236
- code_challenge_methods_supported: ["S256"]
187
+ const proxyProvider = new proxyProvider_js_1.ProxyOAuthServerProvider({
188
+ endpoints: {
189
+ authorizationUrl: `${credentials.url}/oauth/authorize`,
190
+ tokenUrl: `${credentials.url}/oauth/token`,
191
+ revocationUrl: `${credentials.url}/oauth/revoke`,
192
+ },
193
+ verifyAccessToken: async (token) => {
194
+ return {
195
+ token,
196
+ clientId: credentials.clientid,
197
+ scopes: ["uaa.resource"],
198
+ };
237
199
  },
200
+ getClient: async (client_id) => {
201
+ return {
202
+ client_secret: credentials.clientsecret,
203
+ client_id,
204
+ redirect_uris: ["http://localhost:3000/callback"], // Temporary value for now
205
+ };
206
+ },
207
+ });
208
+ expressApp.use((0, router_js_1.mcpAuthRouter)({
209
+ provider: proxyProvider,
210
+ issuerUrl: new URL(credentials.url),
211
+ //baseUrl: new URL(""), // I have left this out for the time being due to the defaulting to issuer
238
212
  serviceDocumentationUrl: new URL("https://docs.cloudfoundry.org/api/uaa/version/77.34.0/index.html#authorization"),
239
- resourceServerUrl: new URL(baseUrl),
240
213
  }));
241
- logger_1.LOGGER.info("OAuth proxy configured successfully for XSUAA integration");
242
214
  }
243
215
  /**
244
216
  * Checks whether the requesting user's access matches that of the roles required
@@ -61,7 +61,11 @@ function registerDescribeModelTool(server) {
61
61
  }));
62
62
  const keys = elements.filter((e) => e.key).map((e) => e.name);
63
63
  const sampleTop = 5;
64
- const shortFields = elements.slice(0, 5).map((e) => e.name);
64
+ // Prefer scalar fields for sample selects; exclude associations
65
+ const scalarFields = elements
66
+ .filter((e) => String(e.type).toLowerCase() !== "cds.association")
67
+ .map((e) => e.name);
68
+ const shortFields = scalarFields.slice(0, 5);
65
69
  // Match wrapper tool naming: Service_Entity_mode
66
70
  const entName = String(ent?.name || "entity");
67
71
  const svcPart = service || entName.split(".")[0] || "Service";
@@ -75,7 +79,7 @@ function registerDescribeModelTool(server) {
75
79
  fields: elements,
76
80
  usage: {
77
81
  rationale: "Entity wrapper tools expose CRUD-like operations for LLMs. Prefer query/get globally; create/update must be explicitly enabled by the developer.",
78
- guidance: "Use the *_query tool for retrieval with filters and projections; use *_get with keys for a single record; use *_create/*_update only if enabled and necessary.",
82
+ guidance: "Use the *_query tool for retrieval with filters and projections. All fields in select/where are consistent. For associations, use foreign key fields (e.g., author_ID not author). Use *_get with keys for a single record; use *_create/*_update only if enabled and necessary.",
79
83
  },
80
84
  examples: {
81
85
  list_tool: listName,
@@ -61,6 +61,45 @@ async function resolveServiceInstance(serviceName) {
61
61
  // NOTE: We use plain entity names (service projection) for queries.
62
62
  const MAX_TOP = 200;
63
63
  const TIMEOUT_MS = 10_000; // Standard timeout for tool calls (ms)
64
+ // Map OData operators to CDS/SQL operators for better performance and readability
65
+ const ODATA_TO_CDS_OPERATORS = new Map([
66
+ ["eq", "="],
67
+ ["ne", "!="],
68
+ ["gt", ">"],
69
+ ["ge", ">="],
70
+ ["lt", "<"],
71
+ ["le", "<="],
72
+ ]);
73
+ /**
74
+ * Builds enhanced query tool description with field types and association examples
75
+ */
76
+ function buildEnhancedQueryDescription(resAnno) {
77
+ const associations = Array.from(resAnno.properties.entries())
78
+ .filter(([, cdsType]) => String(cdsType).toLowerCase().includes("association"))
79
+ .map(([name]) => `${name}_ID`);
80
+ const baseDesc = `Query ${resAnno.target} with structured filters, select, orderby, top/skip.`;
81
+ const assocHint = associations.length > 0
82
+ ? ` IMPORTANT: For associations, always use foreign key fields (${associations.join(", ")}) - never use association names directly.`
83
+ : "";
84
+ return baseDesc + assocHint;
85
+ }
86
+ /**
87
+ * Builds field documentation for schema descriptions
88
+ */
89
+ function buildFieldDocumentation(resAnno) {
90
+ const docs = [];
91
+ for (const [propName, cdsType] of resAnno.properties.entries()) {
92
+ const isAssociation = String(cdsType).toLowerCase().includes("association");
93
+ if (isAssociation) {
94
+ docs.push(`${propName}(association: compare by key value)`);
95
+ docs.push(`${propName}_ID(foreign key for ${propName})`);
96
+ }
97
+ else {
98
+ docs.push(`${propName}(${String(cdsType).toLowerCase()})`);
99
+ }
100
+ }
101
+ return docs.join(", ");
102
+ }
64
103
  /**
65
104
  * Registers CRUD-like MCP tools for an annotated entity (resource).
66
105
  * Modes can be controlled globally via configuration and per-entity via @mcp.wrap.
@@ -115,9 +154,27 @@ function nameFor(service, entity, suffix) {
115
154
  function registerQueryTool(resAnno, server, authEnabled) {
116
155
  const toolName = nameFor(resAnno.serviceName, resAnno.target, "query");
117
156
  // Structured input schema for queries with guard for empty property lists
118
- const propKeys = Array.from(resAnno.properties.keys());
119
- const fieldEnum = (propKeys.length
120
- ? zod_1.z.enum(propKeys)
157
+ const allKeys = Array.from(resAnno.properties.keys());
158
+ const scalarKeys = Array.from(resAnno.properties.entries())
159
+ .filter(([, cdsType]) => !String(cdsType).toLowerCase().includes("association"))
160
+ .map(([name]) => name);
161
+ // Add foreign key fields for associations to scalar keys for select/orderby
162
+ for (const [propName, cdsType] of resAnno.properties.entries()) {
163
+ const isAssociation = String(cdsType).toLowerCase().includes("association");
164
+ if (isAssociation) {
165
+ scalarKeys.push(`${propName}_ID`);
166
+ }
167
+ }
168
+ // Build where field enum: use same fields as select (scalar + foreign keys)
169
+ // This ensures consistency - what you can select, you can filter by
170
+ const whereKeys = [...scalarKeys];
171
+ const whereFieldEnum = (whereKeys.length
172
+ ? zod_1.z.enum(whereKeys)
173
+ : zod_1.z
174
+ .enum(["__dummy__"])
175
+ .transform(() => "__dummy__"));
176
+ const selectFieldEnum = (scalarKeys.length
177
+ ? zod_1.z.enum(scalarKeys)
121
178
  : zod_1.z
122
179
  .enum(["__dummy__"])
123
180
  .transform(() => "__dummy__"));
@@ -131,16 +188,21 @@ function registerQueryTool(resAnno, server, authEnabled) {
131
188
  .default(25)
132
189
  .describe("Rows (default 25)"),
133
190
  skip: zod_1.z.number().int().min(0).default(0).describe("Offset"),
134
- select: zod_1.z.array(fieldEnum).optional(),
191
+ select: zod_1.z
192
+ .array(selectFieldEnum)
193
+ .optional()
194
+ .transform((val) => val && val.length > 0 ? val : undefined)
195
+ .describe(`Select/orderby allow only scalar fields: ${scalarKeys.join(", ")}`),
135
196
  orderby: zod_1.z
136
197
  .array(zod_1.z.object({
137
- field: fieldEnum,
198
+ field: selectFieldEnum,
138
199
  dir: zod_1.z.enum(["asc", "desc"]).default("asc"),
139
200
  }))
140
- .optional(),
201
+ .optional()
202
+ .transform((val) => val && val.length > 0 ? val : undefined),
141
203
  where: zod_1.z
142
204
  .array(zod_1.z.object({
143
- field: fieldEnum,
205
+ field: whereFieldEnum.describe(`FILTERABLE FIELDS: ${scalarKeys.join(", ")}. For associations use foreign key (author_ID), NOT association name (author).`),
144
206
  op: zod_1.z.enum([
145
207
  "eq",
146
208
  "ne",
@@ -160,15 +222,17 @@ function registerQueryTool(resAnno, server, authEnabled) {
160
222
  zod_1.z.array(zod_1.z.union([zod_1.z.string(), zod_1.z.number()])),
161
223
  ]),
162
224
  }))
163
- .optional(),
225
+ .optional()
226
+ .transform((val) => val && val.length > 0 ? val : undefined),
164
227
  q: zod_1.z.string().optional().describe("Quick text search"),
165
228
  return: zod_1.z.enum(["rows", "count", "aggregate"]).default("rows").optional(),
166
229
  aggregate: zod_1.z
167
230
  .array(zod_1.z.object({
168
- field: fieldEnum,
231
+ field: selectFieldEnum,
169
232
  fn: zod_1.z.enum(["sum", "avg", "min", "max", "count"]),
170
233
  }))
171
- .optional(),
234
+ .optional()
235
+ .transform((val) => (val && val.length > 0 ? val : undefined)),
172
236
  explain: zod_1.z.boolean().optional(),
173
237
  })
174
238
  .strict();
@@ -184,7 +248,8 @@ function registerQueryTool(resAnno, server, authEnabled) {
184
248
  explain: inputZod.shape.explain,
185
249
  };
186
250
  const hint = resAnno.wrap?.hint ? ` Hint: ${resAnno.wrap?.hint}` : "";
187
- const desc = `List ${resAnno.target}. Use structured filters (where), top/skip/orderby/select. For fields & examples call cap_describe_model.${hint}`;
251
+ const desc = `${buildEnhancedQueryDescription(resAnno)} CRITICAL: Use foreign key fields (e.g., author_ID) for associations - association names (e.g., author) won't work in filters.` +
252
+ hint;
188
253
  const queryHandler = async (rawArgs) => {
189
254
  const parsed = inputZod.safeParse(rawArgs);
190
255
  if (!parsed.success) {
@@ -203,7 +268,7 @@ function registerQueryTool(resAnno, server, authEnabled) {
203
268
  }
204
269
  let q;
205
270
  try {
206
- q = buildQuery(CDS, args, resAnno, propKeys);
271
+ q = buildQuery(CDS, args, resAnno, allKeys);
207
272
  }
208
273
  catch (e) {
209
274
  return (0, utils_2.toolError)("FILTER_PARSE_ERROR", e?.message || String(e));
@@ -564,8 +629,9 @@ function buildQuery(CDS, args, resAnno, propKeys) {
564
629
  let qy = SELECT.from(resAnno.target).limit(limitTop, limitSkip);
565
630
  if ((propKeys?.length ?? 0) === 0)
566
631
  return qy;
567
- if (args.select?.length)
632
+ if (args.select?.length) {
568
633
  qy = qy.columns(...args.select);
634
+ }
569
635
  if (args.orderby?.length) {
570
636
  // Map to CQN-compatible order by fragments
571
637
  const orderFragments = args.orderby.map((o) => `${o.field} ${o.dir}`);
@@ -575,29 +641,40 @@ function buildQuery(CDS, args, resAnno, propKeys) {
575
641
  const ands = [];
576
642
  if (args.q) {
577
643
  const textFields = Array.from(resAnno.properties.keys()).filter((k) => /string/i.test(String(resAnno.properties.get(k))));
578
- const ors = textFields.map((f) => CDS.parse.expr(`contains(${f}, '${String(args.q).replace(/'/g, "''")}')`));
579
- if (ors.length)
580
- ands.push(CDS.parse.expr(ors.map((x) => `(${x})`).join(" or ")));
644
+ const escaped = String(args.q).replace(/'/g, "''");
645
+ const ors = textFields.map((f) => `contains(${f}, '${escaped}')`);
646
+ if (ors.length) {
647
+ const orExpr = ors.map((x) => `(${x})`).join(" or ");
648
+ ands.push(CDS.parse.expr(orExpr));
649
+ }
581
650
  }
582
651
  for (const c of args.where || []) {
583
652
  const { field, op, value } = c;
653
+ // Field names are now consistent - use them directly
654
+ const actualField = field;
584
655
  if (op === "in" && Array.isArray(value)) {
585
656
  const list = value
586
657
  .map((v) => typeof v === "string" ? `'${v.replace(/'/g, "''")}'` : String(v))
587
658
  .join(",");
588
- ands.push(CDS.parse.expr(`${field} in (${list})`));
659
+ ands.push(CDS.parse.expr(`${actualField} in (${list})`));
589
660
  continue;
590
661
  }
591
662
  const lit = typeof value === "string"
592
663
  ? `'${String(value).replace(/'/g, "''")}'`
593
664
  : String(value);
665
+ // Map OData operators to CDS/SQL operators
666
+ const cdsOp = ODATA_TO_CDS_OPERATORS.get(op) ?? op;
594
667
  const expr = ["contains", "startswith", "endswith"].includes(op)
595
- ? `${op}(${field}, ${lit})`
596
- : `${field} ${op} ${lit}`;
668
+ ? `${op}(${actualField}, ${lit})`
669
+ : `${actualField} ${cdsOp} ${lit}`;
597
670
  ands.push(CDS.parse.expr(expr));
598
671
  }
599
- if (ands.length)
600
- qy = qy.where(ands);
672
+ if (ands.length) {
673
+ // Apply each condition individually - CDS will AND them together
674
+ for (const condition of ands) {
675
+ qy = qy.where(condition);
676
+ }
677
+ }
601
678
  }
602
679
  return qy;
603
680
  }
package/lib/mcp.js CHANGED
@@ -40,9 +40,7 @@ class McpPlugin {
40
40
  logger_1.LOGGER.debug("Event received for 'bootstrap'");
41
41
  this.expressApp = app;
42
42
  this.expressApp.use("/mcp", express_1.default.json());
43
- // To make it more safe, if there is a mispelling we will always implement auth
44
- // Users will have to explicitly write none
45
- if (this.config.auth !== "none") {
43
+ if (this.config.auth === "inherit") {
46
44
  (0, utils_2.registerAuthMiddleware)(this.expressApp);
47
45
  }
48
46
  await this.registerApiEndpoints();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavdi/cap-mcp",
3
- "version": "0.9.9-alpha.3",
3
+ "version": "0.9.9",
4
4
  "description": "MCP Pluging for CAP",
5
5
  "keywords": [
6
6
  "MCP",
@@ -41,7 +41,7 @@
41
41
  "express": "^4"
42
42
  },
43
43
  "dependencies": {
44
- "@modelcontextprotocol/sdk": "^1.17.4",
44
+ "@modelcontextprotocol/sdk": "^1.17.3",
45
45
  "zod": "^3.25.67",
46
46
  "zod-to-json-schema": "^3.24.5"
47
47
  },
package/lib/.DS_Store DELETED
Binary file
@@ -1,257 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.McpPromptAnnotation = exports.McpToolAnnotation = exports.McpResourceAnnotation = exports.McpAnnotation = exports.McpAnnotations = exports.McpAnnotationKey = void 0;
4
- exports.parseAnnotations = parseAnnotations;
5
- const utils_1 = require("./utils");
6
- const DEFAULT_ALL_RESOURCE_OPTIONS = new Set([
7
- "filter",
8
- "sort",
9
- "top",
10
- "skip",
11
- "select",
12
- ]);
13
- exports.McpAnnotationKey = "@mcp";
14
- exports.McpAnnotations = {
15
- // Resource annotations for MCP
16
- MCP_RESOURCE: "@mcp.resource",
17
- // Tool annotations for MCP
18
- MCP_TOOL_NAME: "@mcp.tool.name",
19
- MCP_TOOL_DESCRIPTION: "@mcp.tool.description",
20
- // Prompt annotations for MCP
21
- MCP_PROMPT: "@mcp.prompt",
22
- };
23
- class McpAnnotation {
24
- _target;
25
- _serviceName;
26
- constructor(target, serviceName) {
27
- this._target = target;
28
- this._serviceName = serviceName;
29
- }
30
- get target() {
31
- return this._target;
32
- }
33
- get serviceName() {
34
- return this._serviceName;
35
- }
36
- }
37
- exports.McpAnnotation = McpAnnotation;
38
- class McpResourceAnnotation extends McpAnnotation {
39
- _includeAll;
40
- _functionalities;
41
- _properties;
42
- constructor(target, serviceName, includeAll, functionalities, properties) {
43
- super(target, serviceName);
44
- this._includeAll = includeAll;
45
- this._functionalities = functionalities;
46
- this._properties = properties;
47
- }
48
- get includeAll() {
49
- return this._includeAll;
50
- }
51
- get functionalities() {
52
- return this._functionalities;
53
- }
54
- get properties() {
55
- return this._properties;
56
- }
57
- }
58
- exports.McpResourceAnnotation = McpResourceAnnotation;
59
- class McpToolAnnotation extends McpAnnotation {
60
- _name;
61
- _description;
62
- _parameters;
63
- _entityKey;
64
- _operationKind;
65
- _keyTypeMap;
66
- constructor(name, description, operation, serviceName, parameters, entityKey, operationKind, keyTypeMap) {
67
- super(operation, serviceName);
68
- this._name = name;
69
- this._description = description;
70
- this._parameters = parameters;
71
- this._entityKey = entityKey;
72
- this._operationKind = operationKind;
73
- this._keyTypeMap = keyTypeMap;
74
- }
75
- get name() {
76
- return this._name;
77
- }
78
- get description() {
79
- return this._description;
80
- }
81
- get parameters() {
82
- return this._parameters;
83
- }
84
- get entityKey() {
85
- return this._entityKey;
86
- }
87
- get operationKind() {
88
- return this._operationKind;
89
- }
90
- get keyTypeMap() {
91
- return this._keyTypeMap;
92
- }
93
- }
94
- exports.McpToolAnnotation = McpToolAnnotation;
95
- class McpPromptAnnotation extends McpAnnotation {
96
- _name;
97
- _template;
98
- constructor(target, serviceName, name, template) {
99
- super(target, serviceName);
100
- this._name = name;
101
- this._template = template;
102
- }
103
- get name() {
104
- return this._name;
105
- }
106
- get template() {
107
- return this._template;
108
- }
109
- }
110
- exports.McpPromptAnnotation = McpPromptAnnotation;
111
- function parseAnnotations(services) {
112
- const annotations = [];
113
- for (const serviceName of Object.keys(services)) {
114
- const srv = services[serviceName];
115
- if (srv.name === "CatalogService") {
116
- utils_1.LOGGER.debug("SERVICE: ", srv.model.definitions);
117
- }
118
- const entities = srv.entities;
119
- const operations = srv.operations; // Refers to action and function imports
120
- // Find entities
121
- for (const entityName of Object.keys(entities)) {
122
- const target = entities[entityName];
123
- const res = findEntityAnnotations(target, entityName, srv);
124
- if (target.actions) {
125
- const bound = parseBoundOperations(target.actions, entityName, target, srv);
126
- if (bound && bound.length > 0) {
127
- annotations.push(...bound);
128
- }
129
- }
130
- if (!res)
131
- continue;
132
- annotations.push(res);
133
- }
134
- // Find operations
135
- for (const operationName of Object.keys(operations)) {
136
- const op = operations[operationName];
137
- const res = findOperationAnnotations(op, operationName, srv);
138
- if (!res)
139
- continue;
140
- annotations.push(res);
141
- }
142
- }
143
- const result = formatAnnotations(annotations);
144
- return result;
145
- }
146
- function formatAnnotations(annotationList) {
147
- const result = new Map();
148
- for (const annotation of annotationList) {
149
- if (annotation.operation) {
150
- if (!annotation.annotations[exports.McpAnnotations.MCP_TOOL_NAME] ||
151
- !annotation.annotations[exports.McpAnnotations.MCP_TOOL_DESCRIPTION]) {
152
- utils_1.LOGGER.error(`Invalid annotation found for operation`, annotation);
153
- throw new Error(`Invalid annotations for operation '${annotation.operation}'`);
154
- }
155
- else if (typeof annotation.annotations[exports.McpAnnotations.MCP_TOOL_NAME] !==
156
- "string" ||
157
- typeof annotation.annotations[exports.McpAnnotations.MCP_TOOL_DESCRIPTION] !==
158
- "string") {
159
- utils_1.LOGGER.error("Invalid data for annotations", annotation);
160
- throw new Error(`Invalid annotation data for operation '${annotation.operation}'`);
161
- }
162
- const entry = new McpToolAnnotation(annotation.annotations[exports.McpAnnotations.MCP_TOOL_NAME], annotation.annotations[exports.McpAnnotations.MCP_TOOL_DESCRIPTION], annotation.operation, annotation.serviceName, mapOperationInput(annotation.context), // TODO: Parse the parameters from the context and place them in the class
163
- annotation.entityKey, annotation.operationKind, annotation.keyTypeMap);
164
- result.set(entry.target, entry);
165
- continue;
166
- }
167
- if (!annotation.entityKey) {
168
- utils_1.LOGGER.error("Invalid entry", annotation);
169
- throw new Error(`Invalid annotated entry found with no target`);
170
- }
171
- if (!annotation.annotations[exports.McpAnnotations.MCP_RESOURCE]) {
172
- utils_1.LOGGER.error("No valid annotations found for entry", annotation);
173
- throw new Error(`Invalid annotations for entry target: '${annotation.entityKey}'`);
174
- }
175
- const includeAll = annotation.annotations[exports.McpAnnotations.MCP_RESOURCE] === true;
176
- const functionalities = Array.isArray(annotation.annotations[exports.McpAnnotations.MCP_RESOURCE])
177
- ? new Set(annotation.annotations[exports.McpAnnotations.MCP_RESOURCE])
178
- : DEFAULT_ALL_RESOURCE_OPTIONS;
179
- const entry = new McpResourceAnnotation(annotation.entityKey, annotation.serviceName, includeAll, functionalities, (0, utils_1.parseEntityElements)(annotation.context));
180
- result.set(entry.target, entry);
181
- }
182
- utils_1.LOGGER.debug("Formatted annotations", result);
183
- return result;
184
- }
185
- function findEntityAnnotations(entry, entityKey, service) {
186
- const annotations = findAnnotations(entry);
187
- return Object.keys(annotations).length > 0
188
- ? {
189
- serviceName: service.name,
190
- annotations: annotations,
191
- entityKey: entityKey,
192
- context: entry,
193
- }
194
- : undefined;
195
- }
196
- function findOperationAnnotations(operation, operationName, service) {
197
- const annotations = findAnnotations(operation);
198
- return Object.keys(annotations).length > 0
199
- ? {
200
- serviceName: service.name,
201
- annotations: annotations,
202
- operation: operationName,
203
- operationKind: operation.kind,
204
- context: operation,
205
- }
206
- : undefined;
207
- }
208
- function parseBoundOperations(operations, entityKey, entity, service) {
209
- const res = new Array();
210
- for (const [operationName, operation] of Object.entries(operations)) {
211
- const annotation = findBoundOperationAnnotations(operation, operationName, entityKey, service);
212
- if (!annotation)
213
- continue;
214
- annotation.keyTypeMap = new Map();
215
- for (const [k, v] of Object.entries(entity.keys)) {
216
- if (!v.type) {
217
- utils_1.LOGGER.error("Invalid key type", k);
218
- throw new Error("Invalid key type found for bound operation");
219
- }
220
- annotation.keyTypeMap.set(k, v.type.replace("cds.", ""));
221
- }
222
- res.push(annotation);
223
- }
224
- return res;
225
- }
226
- function findBoundOperationAnnotations(operation, operationName, entityKey, service) {
227
- const annotations = findAnnotations(operation);
228
- return Object.keys(annotations).length > 0
229
- ? {
230
- serviceName: service.name,
231
- annotations: annotations,
232
- operation: operationName,
233
- operationKind: operation.kind,
234
- entityKey: entityKey,
235
- context: operation,
236
- }
237
- : undefined;
238
- }
239
- function findAnnotations(entry) {
240
- const annotations = {};
241
- for (const [k, v] of Object.entries(entry)) {
242
- if (!k.includes(exports.McpAnnotationKey))
243
- continue;
244
- annotations[k] = v;
245
- }
246
- return annotations;
247
- }
248
- function mapOperationInput(ctx) {
249
- const params = ctx["params"];
250
- if (!params)
251
- return undefined;
252
- const result = new Map();
253
- for (const [k, v] of Object.entries(params)) {
254
- result.set(k, v.type.replace("cds.", ""));
255
- }
256
- return result.size > 0 ? result : undefined;
257
- }
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
package/lib/auth/mock.js DELETED
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
package/lib/auth/types.js DELETED
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,156 +0,0 @@
1
- "use strict";
2
- /**
3
- * Custom URI template implementation that fixes the MCP SDK's broken
4
- * URI template matching for grouped query parameters.
5
- *
6
- * This is duck typing implementation of the ResourceTemplate class.
7
- * See @modelcontextprotocol/sdk/server/mcp.js
8
- *
9
- * This is only a temporary solution, as we should use the official implementation from the SDK
10
- * Upon the SDK being fixed, we should switch over to that implementation.
11
- */
12
- Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.CustomResourceTemplate = exports.CustomUriTemplate = void 0;
14
- // TODO: Get rid of 'any' typing
15
- /**
16
- * Custom URI template class that properly handles grouped query parameters
17
- * in the format {?param1,param2,param3}
18
- */
19
- class CustomUriTemplate {
20
- template;
21
- baseUri = "";
22
- queryParams = [];
23
- constructor(template) {
24
- this.template = template;
25
- this.parseTemplate();
26
- }
27
- toString() {
28
- return this.template;
29
- }
30
- parseTemplate() {
31
- // Extract base URI and query parameters from template
32
- // Template format: odata://CatalogService/books{?filter,orderby,select,skip,top}
33
- const queryTemplateMatch = this.template.match(/^([^{]+)\{\?([^}]+)\}$/);
34
- if (!queryTemplateMatch) {
35
- // No query parameters, treat as static URI
36
- this.baseUri = this.template;
37
- this.queryParams = [];
38
- return;
39
- }
40
- this.baseUri = queryTemplateMatch[1];
41
- this.queryParams = queryTemplateMatch[2]
42
- .split(",")
43
- .map((param) => param.trim())
44
- .filter((param) => param.length > 0);
45
- }
46
- /**
47
- * Matches a URI against this template and extracts variables
48
- * @param uri The URI to match
49
- * @returns Object with extracted variables or null if no match
50
- */
51
- match(uri) {
52
- // Check if base URI matches
53
- if (!uri.startsWith(this.baseUri)) {
54
- return null;
55
- }
56
- // Extract query string
57
- const queryStart = uri.indexOf("?");
58
- if (queryStart === -1) {
59
- // No query parameters in URI
60
- if (this.queryParams.length === 0) {
61
- return {}; // Static URI match
62
- }
63
- // Template expects query params but URI has none - still valid for optional params
64
- return {};
65
- }
66
- const queryString = uri.substring(queryStart + 1);
67
- const queryPairs = queryString.split("&");
68
- const extractedVars = {};
69
- // Parse query parameters with strict validation
70
- for (const pair of queryPairs) {
71
- const equalIndex = pair.indexOf("=");
72
- if (equalIndex > 0) {
73
- const key = pair.substring(0, equalIndex);
74
- const value = pair.substring(equalIndex + 1);
75
- if (key && value !== undefined) {
76
- const decodedKey = decodeURIComponent(key);
77
- const decodedValue = decodeURIComponent(value);
78
- // SECURITY: Reject entire URI if ANY unauthorized parameter is present
79
- if (!this.queryParams.includes(decodedKey)) {
80
- return null; // Unauthorized parameter found - reject entire URI
81
- }
82
- extractedVars[decodedKey] = decodedValue;
83
- }
84
- }
85
- else if (pair.trim().length > 0) {
86
- // Handle malformed parameters (missing = or empty key)
87
- // SECURITY: Reject malformed query parameters
88
- return null;
89
- }
90
- }
91
- // For static templates (no parameters allowed), reject any query string
92
- if (this.queryParams.length === 0 && queryString.trim().length > 0) {
93
- return null;
94
- }
95
- return extractedVars;
96
- }
97
- /**
98
- * Expands the template with given variables
99
- * @param variables Object containing variable values
100
- * @returns Expanded URI string
101
- */
102
- expand(variables) {
103
- if (this.queryParams.length === 0) {
104
- return this.baseUri;
105
- }
106
- const queryPairs = [];
107
- for (const param of this.queryParams) {
108
- const value = variables[param];
109
- if (value !== undefined && value !== null && value !== "") {
110
- queryPairs.push(`${encodeURIComponent(param)}=${encodeURIComponent(value)}`);
111
- }
112
- }
113
- if (queryPairs.length === 0) {
114
- return this.baseUri;
115
- }
116
- return `${this.baseUri}?${queryPairs.join("&")}`;
117
- }
118
- /**
119
- * Gets the variable names from the template
120
- */
121
- get variableNames() {
122
- return [...this.queryParams];
123
- }
124
- }
125
- exports.CustomUriTemplate = CustomUriTemplate;
126
- /**
127
- * Custom ResourceTemplate that uses our CustomUriTemplate for proper URI matching
128
- * Duck-types the MCP SDK's ResourceTemplate interface for compatibility
129
- */
130
- class CustomResourceTemplate {
131
- _uriTemplate;
132
- _callbacks;
133
- constructor(uriTemplate, callbacks) {
134
- this._callbacks = callbacks;
135
- this._uriTemplate = new CustomUriTemplate(uriTemplate);
136
- }
137
- /**
138
- * Gets the URI template pattern - must match MCP SDK interface
139
- */
140
- get uriTemplate() {
141
- return this._uriTemplate;
142
- }
143
- /**
144
- * Gets the list callback, if one was provided
145
- */
146
- get listCallback() {
147
- return this._callbacks.list;
148
- }
149
- /**
150
- * Gets the callback for completing a specific URI template variable
151
- */
152
- completeCallback(variable) {
153
- return this._callbacks.complete?.[variable];
154
- }
155
- }
156
- exports.CustomResourceTemplate = CustomResourceTemplate;
package/lib/types.js DELETED
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
package/lib/utils.js DELETED
@@ -1,136 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MCP_SESSION_HEADER = exports.LOGGER = void 0;
4
- exports.createMcpServer = createMcpServer;
5
- exports.handleMcpSessionRequest = handleMcpSessionRequest;
6
- exports.parseEntityElements = parseEntityElements;
7
- const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
8
- const zod_1 = require("zod");
9
- const structures_1 = require("./annotations/structures");
10
- /* @ts-ignore */
11
- const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
12
- exports.LOGGER = cds.log("cds-mcp");
13
- exports.MCP_SESSION_HEADER = "mcp-session-id";
14
- function createMcpServer(annotations) {
15
- const packageInfo = require("../package.json");
16
- const server = new mcp_js_1.McpServer({
17
- name: packageInfo.name,
18
- version: packageInfo.version,
19
- capabilities: {
20
- tools: { listChanged: true },
21
- resources: { listChanged: true },
22
- prompts: { listChanged: true },
23
- },
24
- });
25
- exports.LOGGER.debug("Annotations found for server = ", annotations);
26
- if (!annotations)
27
- return server;
28
- // TODO: Handle the parsed annotations
29
- // TODO: Error handling
30
- // TODO: This should only be mapped once, not per each server instance. Maybe this should be pre-packaged on load?
31
- for (const [_, v] of annotations.entries()) {
32
- switch (v.constructor) {
33
- case structures_1.McpToolAnnotation:
34
- const model = v;
35
- const parameters = buildParameters(model.parameters);
36
- exports.LOGGER.debug("Adding tool", model);
37
- if (model.entityKey) {
38
- const keys = buildParameters(model.keyTypeMap);
39
- server.tool(model.name, { ...keys, ...parameters }, async (data) => {
40
- const service = cds.services[model.serviceName];
41
- const received = data;
42
- const receivedKeys = {};
43
- const receivedParams = {};
44
- for (const [k, v] of Object.entries(received)) {
45
- if (model.keyTypeMap?.has(k)) {
46
- receivedKeys[k] = v;
47
- }
48
- if (!model.parameters?.has(k))
49
- continue;
50
- receivedParams[k] = v;
51
- }
52
- const response = await service.send({
53
- event: model.target,
54
- entity: model.entityKey,
55
- data: receivedParams,
56
- params: [receivedKeys],
57
- });
58
- return {
59
- content: Array.isArray(response)
60
- ? response.map((el) => ({ type: "text", text: String(el) }))
61
- : [{ type: "text", text: String(response) }], // TODO: This should be dynamic based on the return type
62
- };
63
- });
64
- continue;
65
- }
66
- server.tool(model.name, parameters, async (data) => {
67
- exports.LOGGER.debug("Tool call received, targeting service: ", model.serviceName, model.target);
68
- const service = cds.services[model.serviceName];
69
- const response = await service.send(model.target, data);
70
- exports.LOGGER.debug("MCP Tool response received and being packaged");
71
- return {
72
- content: Array.isArray(response)
73
- ? response.map((el) => ({ type: "text", text: String(el) }))
74
- : [{ type: "text", text: String(response) }], // TODO: This should be dynamic based on the return type
75
- };
76
- });
77
- continue;
78
- case structures_1.McpResourceAnnotation:
79
- exports.LOGGER.debug("This is a resource");
80
- continue;
81
- case structures_1.McpPromptAnnotation:
82
- exports.LOGGER.debug("This is a prompt");
83
- continue;
84
- default:
85
- exports.LOGGER.error("Invalid annotation data type");
86
- throw new Error("Invalid annotation");
87
- }
88
- }
89
- return server;
90
- }
91
- async function handleMcpSessionRequest(req, res, sessions) {
92
- const sessionIdHeader = req.headers[exports.MCP_SESSION_HEADER];
93
- if (!sessionIdHeader || !sessions.has(sessionIdHeader)) {
94
- res.status(400).send("Invalid or missing session ID");
95
- return;
96
- }
97
- const session = sessions.get(sessionIdHeader);
98
- if (!session) {
99
- res.status(400).send("Invalid session");
100
- return;
101
- }
102
- await session.transport.handleRequest(req, res);
103
- }
104
- function parseEntityElements(entity) {
105
- const elements = entity.elements;
106
- if (!elements) {
107
- exports.LOGGER.error("Invalid object - cannot be parsed", entity);
108
- throw new Error("Failed to parse entity object");
109
- }
110
- const result = new Map();
111
- for (const el of elements) {
112
- if (!el.type)
113
- continue;
114
- result.set(el.name, el.type?.replace("cds.", ""));
115
- }
116
- return result;
117
- }
118
- function buildParameters(params) {
119
- if (!params || params.size <= 0)
120
- return {};
121
- const result = {};
122
- for (const [k, v] of params.entries()) {
123
- result[k] = determineParameterType(v);
124
- }
125
- return result;
126
- }
127
- function determineParameterType(paramType) {
128
- switch (paramType) {
129
- case "String":
130
- return zod_1.z.string();
131
- case "Integer":
132
- return zod_1.z.number();
133
- default:
134
- return zod_1.z.number();
135
- }
136
- }