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

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
@@ -3,7 +3,7 @@
3
3
  > This implementation is based on the Model Context Protocol (MCP) put forward by Anthropic.
4
4
  > For more information on MCP, please have a look at their [official documentation.](https://modelcontextprotocol.io/introduction)
5
5
 
6
- > 🔧 **In active development - 1.0 release scheduled for Summer 2025**
6
+ > 🔧 **In active development - 1.0 release scheduled for September 2025**
7
7
 
8
8
  # CAP-MCP Plugin
9
9
 
@@ -15,7 +15,7 @@ Transform your CAP OData services into AI-accessible resources, tools, and promp
15
15
  The Model Context Protocol bridges the gap between your enterprise data and AI agents.
16
16
  By integrating MCP with your CAP applications, you unlock:
17
17
 
18
- - **AI-Native Data Access**: Your CAP services become directly accessible to AI agents like Claude, enabling natural language queries against your business data
18
+ - **AI-Native Data Access**: Your CAP services become directly accessible to MCP enabled AI agents like Claude, enabling natural language queries against your business data
19
19
  - **Enterprise Integration**: Seamlessly connect AI tools to your SAP systems, databases, and business logic
20
20
  - **Intelligent Automation**: Enable AI agents to perform complex business operations by combining multiple CAP service calls
21
21
  - **Developer Productivity**: Allow AI assistants to help developers understand, query, and work with your CAP data models
@@ -23,19 +23,11 @@ By integrating MCP with your CAP applications, you unlock:
23
23
 
24
24
  ## ⚠️ Development Status
25
25
 
26
- **This plugin is currently in active development (v0.9.2) and approaching production readiness.**
26
+ **This plugin is currently in active development and approaching production readiness.**
27
27
  APIs and annotations may change in future releases. Authentication and security features are implemented and tested.
28
28
 
29
29
  Version 1.0 of the plugin is scheduled for release in Summer 2025 after final stability testing and documentation completion.
30
30
 
31
- ## 📦 Installation
32
-
33
- ```bash
34
- npm install @gavdi/cap-mcp
35
- ```
36
-
37
- The plugin follows CAP's standard plugin architecture and will automatically integrate with your CAP application.
38
-
39
31
  ## 🚀 Quick Setup
40
32
 
41
33
  ### Prerequisites
@@ -51,6 +43,8 @@ The plugin follows CAP's standard plugin architecture and will automatically int
51
43
  npm install @gavdi/cap-mcp
52
44
  ```
53
45
 
46
+ The plugin follows CAP's standard plugin architecture and will automatically integrate with your CAP application upon installation.
47
+
54
48
  ### Step 2: Configure Your CAP Application
55
49
 
56
50
  Add MCP configuration to your `package.json`:
@@ -83,7 +77,7 @@ service CatalogService {
83
77
  resource: ['filter', 'orderby', 'select', 'top', 'skip']
84
78
  }
85
79
  entity Books as projection on my.Books;
86
-
80
+
87
81
  // Optionally expose Books as tools for LLMs (query/get enabled by default config)
88
82
  annotate CatalogService.Books with @mcp.wrap: {
89
83
  tools: true,
@@ -135,17 +129,6 @@ This plugin transforms your annotated CAP services into a fully functional MCP s
135
129
  - Start demo app: `npm run mock`
136
130
  - Inspector: `npx @modelcontextprotocol/inspector`
137
131
 
138
- ### New wrapper tools
139
-
140
- When `wrap_entities_to_actions` is enabled (globally or via `@mcp.wrap.tools: true`), you will see tools named like:
141
-
142
- - `CatalogService_Books_query`
143
- - `CatalogService_Books_get`
144
- - `CatalogService_Books_create` (if enabled)
145
- - `CatalogService_Books_update` (if enabled)
146
-
147
- Each tool includes a description with fields and OData notes to guide the model. You can add `@mcp.wrap.hint` per entity to enrich descriptions for LLMs.
148
-
149
132
  ### Bruno collection
150
133
 
151
134
  The `bruno/` folder contains HTTP requests for the MCP endpoint (handy for local manual testing using Bruno or any HTTP client). You may add calls for `tools/list` and `tools/call` to exercise the new wrapper tools.
@@ -197,6 +180,33 @@ service CatalogService {
197
180
  - **Dynamic Filtering**: Complex filter expressions using OData syntax
198
181
  - **Flexible Selection**: Choose specific fields and sort orders
199
182
 
183
+ ### Wrapper tools
184
+
185
+ When `wrap_entities_to_actions` is enabled (globally or via `@mcp.wrap.tools: true`), you will see tools named like:
186
+
187
+ - `CatalogService_Books_query`
188
+ - `CatalogService_Books_get`
189
+ - `CatalogService_Books_create` (if enabled)
190
+ - `CatalogService_Books_update` (if enabled)
191
+
192
+ Each tool includes a description with fields and OData notes to guide the model. You can add `@mcp.wrap.hint` per entity to enrich descriptions for LLMs.
193
+
194
+ Example:
195
+
196
+ ```cds
197
+ // Wrap Books entity as tools for query/get/create/update (demo)
198
+ annotate CatalogService.Books with @mcp.wrap: {
199
+ tools: true,
200
+ modes: [
201
+ 'query',
202
+ 'get',
203
+ 'create',
204
+ 'update'
205
+ ],
206
+ hint : 'Use for read and write demo operations'
207
+ };
208
+ ```
209
+
200
210
  ### Tool Annotations
201
211
 
202
212
  Convert CAP functions and actions into executable AI tools:
@@ -277,6 +287,7 @@ Configure the MCP plugin through your CAP application's `package.json` or `.cdsr
277
287
  | `name` | string | package.json name | MCP server name |
278
288
  | `version` | string | package.json version | MCP server version |
279
289
  | `auth` | `"inherit"` \| `"none"` | `"inherit"` | Authentication mode |
290
+ | `instructions` | string | `null` | MCP server instructions for agents |
280
291
  | `capabilities.resources.listChanged` | boolean | `true` | Enable resource list change notifications |
281
292
  | `capabilities.resources.subscribe` | boolean | `false` | Enable resource subscriptions |
282
293
  | `capabilities.tools.listChanged` | boolean | `true` | Enable tool list change notifications |
@@ -539,9 +550,24 @@ npm test -- --testPathPattern=integration
539
550
  ## 🚨 Performance & Limitations
540
551
 
541
552
  ### Known Limitations
542
- - **SDK Bug**: Dynamic resource queries require all query parameters due to `@modelcontextprotocol/sdk` RFC template string issue
543
- - **Authentication Inheritance**: MCP authentication is tightly coupled to CAP's authentication system
544
- - **Session Cleanup**: MCP sessions are cleaned up on HTTP connection close, not on explicit disconnect
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
545
571
 
546
572
  ### Performance Considerations
547
573
  - **Large Datasets**: Use `resource: ['top']` or similar constraints for entities with many records
package/lib/.DS_Store ADDED
Binary file
@@ -0,0 +1,257 @@
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
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -2,8 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.authHandlerFactory = authHandlerFactory;
4
4
  exports.errorHandlerFactory = errorHandlerFactory;
5
+ const logger_1 = require("../logger");
5
6
  /** JSON-RPC 2.0 error code for unauthorized requests */
6
7
  const RPC_UNAUTHORIZED = 10;
8
+ /** HTTP Authenticate header **/
9
+ const WWW_AUTHENTICATE = "WWW-Authenticate";
7
10
  /* @ts-ignore */
8
11
  const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
9
12
  /**
@@ -31,9 +34,15 @@ const cds = global.cds || require("@sap/cds"); // This is a work around for miss
31
34
  * @throws {500} When CAP context is not properly loaded
32
35
  */
33
36
  function authHandlerFactory() {
34
- const authKind = cds.env.requires.auth.kind;
35
37
  return (req, res, next) => {
38
+ const auth = cds.env.requires.auth;
39
+ const authKind = auth.kind;
40
+ const credentials = auth.credentials;
36
41
  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"`);
37
46
  res.status(401).json({
38
47
  jsonrpc: "2.0",
39
48
  error: {
@@ -58,6 +67,9 @@ function authHandlerFactory() {
58
67
  }
59
68
  const user = ctx.user;
60
69
  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"`);
61
73
  res.status(401).json({
62
74
  jsonrpc: "2.0",
63
75
  error: {
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/lib/auth/utils.js CHANGED
@@ -6,30 +6,10 @@ 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");
10
9
  const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js");
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
- */
10
+ const logger_1 = require("../logger");
31
11
  /* @ts-ignore */
32
- const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
12
+ const cds = global.cds || require("@sap/cds"); // Use hosting app's CDS instance exclusively
33
13
  /**
34
14
  * Determines whether authentication is enabled for the MCP plugin.
35
15
  *
@@ -117,7 +97,8 @@ function getAccessRights(authEnabled) {
117
97
  * @since 1.0.0
118
98
  */
119
99
  function registerAuthMiddleware(expressApp) {
120
- const middlewares = cds.middlewares.before; // No types exists for this part of the CDS library
100
+ logger_1.LOGGER.debug("Configuring auth middleware");
101
+ const middlewares = cds.middlewares?.before || []; // Handle missing middlewares gracefully
121
102
  // Build array of auth middleware to apply
122
103
  const authMiddleware = [];
123
104
  // Add CAP middleware
@@ -132,8 +113,10 @@ function registerAuthMiddleware(expressApp) {
132
113
  authMiddleware.push((0, handler_1.authHandlerFactory)());
133
114
  // Apply auth middleware to all /mcp routes EXCEPT health
134
115
  expressApp?.use(/^\/mcp(?!\/health).*/, ...authMiddleware);
135
- // Then finally we add the oauth proxy to the xsuaa instance
116
+ // Note: .well-known/oauth-authorization-server endpoint is automatically created by mcpAuthRouter
117
+ // Configure OAuth proxy for enterprise authentication scenarios
136
118
  configureOAuthProxy(expressApp);
119
+ logger_1.LOGGER.debug("Auth middleware configured");
137
120
  }
138
121
  /**
139
122
  * Configures OAuth proxy middleware for enterprise authentication scenarios.
@@ -146,6 +129,7 @@ function registerAuthMiddleware(expressApp) {
146
129
  * - Access token verification and validation
147
130
  * - Client credential management
148
131
  * - Integration with CAP authentication configuration
132
+ * - Dynamic client registration for MCP clients
149
133
  *
150
134
  * The OAuth proxy is only configured for enterprise authentication types
151
135
  * (jwt, xsuaa, ias) and skips configuration for basic auth types.
@@ -175,42 +159,86 @@ function configureOAuthProxy(expressApp) {
175
159
  const config = cds.env.requires.auth;
176
160
  const kind = config.kind;
177
161
  const credentials = config.credentials;
162
+ logger_1.LOGGER.debug("Running auth with configuration kind", kind);
178
163
  // Safety guard - skip OAuth proxy for basic auth types
179
- if (kind === "dummy" || kind === "mocked" || kind === "basic")
164
+ if (kind === "dummy" || kind === "mocked" || kind === "basic") {
165
+ logger_1.LOGGER.debug("Skipping OAuth proxy for auth type:", kind);
180
166
  return;
181
- else if (!credentials ||
167
+ }
168
+ if (!credentials ||
182
169
  !credentials.clientid ||
183
170
  !credentials.clientsecret ||
184
171
  !credentials.url) {
185
- throw new Error("Invalid security credentials");
172
+ logger_1.LOGGER.warn("OAuth proxy skipped - missing required XSUAA credentials");
173
+ return; // Don't throw error, just skip OAuth proxy
186
174
  }
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
- };
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"]
199
237
  },
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
212
238
  serviceDocumentationUrl: new URL("https://docs.cloudfoundry.org/api/uaa/version/77.34.0/index.html#authorization"),
239
+ resourceServerUrl: new URL(baseUrl),
213
240
  }));
241
+ logger_1.LOGGER.info("OAuth proxy configured successfully for XSUAA integration");
214
242
  }
215
243
  /**
216
244
  * Checks whether the requesting user's access matches that of the roles required
@@ -0,0 +1,156 @@
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/mcp.js CHANGED
@@ -40,7 +40,9 @@ 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
- if (this.config.auth === "inherit") {
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") {
44
46
  (0, utils_2.registerAuthMiddleware)(this.expressApp);
45
47
  }
46
48
  await this.registerApiEndpoints();
package/lib/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/lib/utils.js ADDED
@@ -0,0 +1,136 @@
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavdi/cap-mcp",
3
- "version": "0.9.8",
3
+ "version": "0.9.9-alpha.3",
4
4
  "description": "MCP Pluging for CAP",
5
5
  "keywords": [
6
6
  "MCP",
@@ -41,12 +41,12 @@
41
41
  "express": "^4"
42
42
  },
43
43
  "dependencies": {
44
- "@modelcontextprotocol/sdk": "^1.17.1",
44
+ "@modelcontextprotocol/sdk": "^1.17.4",
45
45
  "zod": "^3.25.67",
46
46
  "zod-to-json-schema": "^3.24.5"
47
47
  },
48
48
  "devDependencies": {
49
- "@cap-js/cds-types": "^0.12.0",
49
+ "@cap-js/cds-types": "^0.13.0",
50
50
  "@release-it/conventional-changelog": "^10.0.1",
51
51
  "@types/express": "^5.0.3",
52
52
  "@types/jest": "^30.0.0",