@gavdi/cap-mcp 0.9.7 → 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 +55 -27
- package/lib/.DS_Store +0 -0
- package/lib/annotations/constants.js +9 -1
- package/lib/annotations/parser.js +15 -3
- package/lib/annotations/structures.js +20 -6
- package/lib/annotations/utils.js +62 -0
- package/lib/annotations.js +257 -0
- package/lib/auth/adapter.js +2 -0
- package/lib/auth/handler.js +13 -1
- package/lib/auth/mock.js +2 -0
- package/lib/auth/types.js +2 -0
- package/lib/auth/utils.js +139 -51
- package/lib/config/loader.js +1 -0
- package/lib/mcp/customResourceTemplate.js +156 -0
- package/lib/mcp/entity-tools.js +86 -6
- package/lib/mcp/factory.js +9 -3
- package/lib/mcp.js +4 -2
- package/lib/types.js +2 -0
- package/lib/utils.js +136 -0
- package/package.json +3 -3
package/lib/auth/utils.js
CHANGED
|
@@ -3,31 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.isAuthEnabled = isAuthEnabled;
|
|
4
4
|
exports.getAccessRights = getAccessRights;
|
|
5
5
|
exports.registerAuthMiddleware = registerAuthMiddleware;
|
|
6
|
+
exports.hasToolOperationAccess = hasToolOperationAccess;
|
|
7
|
+
exports.getWrapAccesses = getWrapAccesses;
|
|
6
8
|
const handler_1 = require("./handler");
|
|
7
|
-
const proxyProvider_js_1 = require("@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js");
|
|
8
9
|
const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js");
|
|
9
|
-
|
|
10
|
-
* @fileoverview Authentication utilities for MCP-CAP integration.
|
|
11
|
-
*
|
|
12
|
-
* This module provides utilities for integrating CAP authentication with MCP servers.
|
|
13
|
-
* It supports all standard CAP authentication types and provides functions for:
|
|
14
|
-
* - Determining authentication status
|
|
15
|
-
* - Managing user access rights
|
|
16
|
-
* - Registering authentication middleware
|
|
17
|
-
*
|
|
18
|
-
* Supported CAP authentication types:
|
|
19
|
-
* - 'dummy': No authentication (privileged access)
|
|
20
|
-
* - 'mocked': Mock users with predefined credentials
|
|
21
|
-
* - 'basic': HTTP Basic Authentication
|
|
22
|
-
* - 'jwt': Generic JWT token validation
|
|
23
|
-
* - 'xsuaa': SAP BTP XSUAA OAuth2/JWT authentication
|
|
24
|
-
* - 'ias': SAP Identity Authentication Service
|
|
25
|
-
* - Custom string types for user-defined authentication strategies
|
|
26
|
-
*
|
|
27
|
-
* Access CAP auth configuration via: cds.env.requires.auth.kind
|
|
28
|
-
*/
|
|
10
|
+
const logger_1 = require("../logger");
|
|
29
11
|
/* @ts-ignore */
|
|
30
|
-
const cds = global.cds || require("@sap/cds"); //
|
|
12
|
+
const cds = global.cds || require("@sap/cds"); // Use hosting app's CDS instance exclusively
|
|
31
13
|
/**
|
|
32
14
|
* Determines whether authentication is enabled for the MCP plugin.
|
|
33
15
|
*
|
|
@@ -115,7 +97,8 @@ function getAccessRights(authEnabled) {
|
|
|
115
97
|
* @since 1.0.0
|
|
116
98
|
*/
|
|
117
99
|
function registerAuthMiddleware(expressApp) {
|
|
118
|
-
|
|
100
|
+
logger_1.LOGGER.debug("Configuring auth middleware");
|
|
101
|
+
const middlewares = cds.middlewares?.before || []; // Handle missing middlewares gracefully
|
|
119
102
|
// Build array of auth middleware to apply
|
|
120
103
|
const authMiddleware = [];
|
|
121
104
|
// Add CAP middleware
|
|
@@ -130,8 +113,10 @@ function registerAuthMiddleware(expressApp) {
|
|
|
130
113
|
authMiddleware.push((0, handler_1.authHandlerFactory)());
|
|
131
114
|
// Apply auth middleware to all /mcp routes EXCEPT health
|
|
132
115
|
expressApp?.use(/^\/mcp(?!\/health).*/, ...authMiddleware);
|
|
133
|
-
//
|
|
116
|
+
// Note: .well-known/oauth-authorization-server endpoint is automatically created by mcpAuthRouter
|
|
117
|
+
// Configure OAuth proxy for enterprise authentication scenarios
|
|
134
118
|
configureOAuthProxy(expressApp);
|
|
119
|
+
logger_1.LOGGER.debug("Auth middleware configured");
|
|
135
120
|
}
|
|
136
121
|
/**
|
|
137
122
|
* Configures OAuth proxy middleware for enterprise authentication scenarios.
|
|
@@ -144,6 +129,7 @@ function registerAuthMiddleware(expressApp) {
|
|
|
144
129
|
* - Access token verification and validation
|
|
145
130
|
* - Client credential management
|
|
146
131
|
* - Integration with CAP authentication configuration
|
|
132
|
+
* - Dynamic client registration for MCP clients
|
|
147
133
|
*
|
|
148
134
|
* The OAuth proxy is only configured for enterprise authentication types
|
|
149
135
|
* (jwt, xsuaa, ias) and skips configuration for basic auth types.
|
|
@@ -173,40 +159,142 @@ function configureOAuthProxy(expressApp) {
|
|
|
173
159
|
const config = cds.env.requires.auth;
|
|
174
160
|
const kind = config.kind;
|
|
175
161
|
const credentials = config.credentials;
|
|
162
|
+
logger_1.LOGGER.debug("Running auth with configuration kind", kind);
|
|
176
163
|
// Safety guard - skip OAuth proxy for basic auth types
|
|
177
|
-
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);
|
|
178
166
|
return;
|
|
179
|
-
|
|
167
|
+
}
|
|
168
|
+
if (!credentials ||
|
|
180
169
|
!credentials.clientid ||
|
|
181
170
|
!credentials.clientsecret ||
|
|
182
171
|
!credentials.url) {
|
|
183
|
-
|
|
172
|
+
logger_1.LOGGER.warn("OAuth proxy skipped - missing required XSUAA credentials");
|
|
173
|
+
return; // Don't throw error, just skip OAuth proxy
|
|
184
174
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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"]
|
|
204
237
|
},
|
|
205
|
-
});
|
|
206
|
-
expressApp.use((0, router_js_1.mcpAuthRouter)({
|
|
207
|
-
provider: proxyProvider,
|
|
208
|
-
issuerUrl: new URL(credentials.url),
|
|
209
|
-
//baseUrl: new URL(""), // I have left this out for the time being due to the defaulting to issuer
|
|
210
238
|
serviceDocumentationUrl: new URL("https://docs.cloudfoundry.org/api/uaa/version/77.34.0/index.html#authorization"),
|
|
239
|
+
resourceServerUrl: new URL(baseUrl),
|
|
211
240
|
}));
|
|
241
|
+
logger_1.LOGGER.info("OAuth proxy configured successfully for XSUAA integration");
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Checks whether the requesting user's access matches that of the roles required
|
|
245
|
+
* @param user
|
|
246
|
+
* @returns true if the user has access
|
|
247
|
+
*/
|
|
248
|
+
function hasToolOperationAccess(user, roles) {
|
|
249
|
+
// If no restrictions are defined, allow access
|
|
250
|
+
if (!roles || roles.length === 0)
|
|
251
|
+
return true;
|
|
252
|
+
for (const el of roles) {
|
|
253
|
+
if (user.is(el.role))
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Determines wrap accesses based on the given MCP restrictions derived from annotations
|
|
260
|
+
* @param user
|
|
261
|
+
* @param restrictions
|
|
262
|
+
* @returns wrap tool accesses
|
|
263
|
+
*/
|
|
264
|
+
function getWrapAccesses(user, restrictions) {
|
|
265
|
+
// If no restrictions are defined, allow all access
|
|
266
|
+
if (!restrictions || restrictions.length === 0) {
|
|
267
|
+
return {
|
|
268
|
+
canRead: true,
|
|
269
|
+
canCreate: true,
|
|
270
|
+
canUpdate: true,
|
|
271
|
+
canDelete: true,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const access = {};
|
|
275
|
+
for (const el of restrictions) {
|
|
276
|
+
// If the user does not even have the role then no reason to check
|
|
277
|
+
if (!user.is(el.role))
|
|
278
|
+
continue;
|
|
279
|
+
if (!el.operations || el.operations.length <= 0) {
|
|
280
|
+
access.canRead = true;
|
|
281
|
+
access.canCreate = true;
|
|
282
|
+
access.canDelete = true;
|
|
283
|
+
access.canUpdate = true;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
if (el.operations.includes("READ")) {
|
|
287
|
+
access.canRead = true;
|
|
288
|
+
}
|
|
289
|
+
if (el.operations.includes("UPDATE")) {
|
|
290
|
+
access.canUpdate = true;
|
|
291
|
+
}
|
|
292
|
+
if (el.operations.includes("CREATE")) {
|
|
293
|
+
access.canCreate = true;
|
|
294
|
+
}
|
|
295
|
+
if (el.operations.includes("DELETE")) {
|
|
296
|
+
access.canDelete = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return access;
|
|
212
300
|
}
|
package/lib/config/loader.js
CHANGED
|
@@ -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/entity-tools.js
CHANGED
|
@@ -66,28 +66,36 @@ const TIMEOUT_MS = 10_000; // Standard timeout for tool calls (ms)
|
|
|
66
66
|
* Modes can be controlled globally via configuration and per-entity via @mcp.wrap.
|
|
67
67
|
*
|
|
68
68
|
* Example tool names (naming is explicit for easier LLM usage):
|
|
69
|
-
* Service_Entity_query, Service_Entity_get, Service_Entity_create, Service_Entity_update
|
|
69
|
+
* Service_Entity_query, Service_Entity_get, Service_Entity_create, Service_Entity_update, Service_Entity_delete
|
|
70
70
|
*/
|
|
71
|
-
function registerEntityWrappers(resAnno, server, authEnabled, defaultModes) {
|
|
71
|
+
function registerEntityWrappers(resAnno, server, authEnabled, defaultModes, accesses) {
|
|
72
72
|
const CDS = global.cds;
|
|
73
73
|
logger_1.LOGGER.debug(`[REGISTRATION TIME] Registering entity wrappers for ${resAnno.serviceName}.${resAnno.target}, available services:`, Object.keys(CDS.services || {}));
|
|
74
74
|
const modes = resAnno.wrap?.modes ?? defaultModes;
|
|
75
|
-
if (modes.includes("query")) {
|
|
75
|
+
if (modes.includes("query") && accesses.canRead) {
|
|
76
76
|
registerQueryTool(resAnno, server, authEnabled);
|
|
77
77
|
}
|
|
78
78
|
if (modes.includes("get") &&
|
|
79
79
|
resAnno.resourceKeys &&
|
|
80
|
-
resAnno.resourceKeys.size > 0
|
|
80
|
+
resAnno.resourceKeys.size > 0 &&
|
|
81
|
+
accesses.canRead) {
|
|
81
82
|
registerGetTool(resAnno, server, authEnabled);
|
|
82
83
|
}
|
|
83
|
-
if (modes.includes("create")) {
|
|
84
|
+
if (modes.includes("create") && accesses.canCreate) {
|
|
84
85
|
registerCreateTool(resAnno, server, authEnabled);
|
|
85
86
|
}
|
|
86
87
|
if (modes.includes("update") &&
|
|
87
88
|
resAnno.resourceKeys &&
|
|
88
|
-
resAnno.resourceKeys.size > 0
|
|
89
|
+
resAnno.resourceKeys.size > 0 &&
|
|
90
|
+
accesses.canUpdate) {
|
|
89
91
|
registerUpdateTool(resAnno, server, authEnabled);
|
|
90
92
|
}
|
|
93
|
+
if (modes.includes("delete") &&
|
|
94
|
+
resAnno.resourceKeys &&
|
|
95
|
+
resAnno.resourceKeys.size > 0 &&
|
|
96
|
+
accesses.canDelete) {
|
|
97
|
+
registerDeleteTool(resAnno, server, authEnabled);
|
|
98
|
+
}
|
|
91
99
|
}
|
|
92
100
|
/**
|
|
93
101
|
* Builds the visible tool name for a given operation mode.
|
|
@@ -474,6 +482,78 @@ function registerUpdateTool(resAnno, server, authEnabled) {
|
|
|
474
482
|
};
|
|
475
483
|
server.registerTool(toolName, { title: toolName, description: desc, inputSchema }, updateHandler);
|
|
476
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* Registers the delete tool for an entity.
|
|
487
|
+
* Requires keys to identify the entity to delete.
|
|
488
|
+
*/
|
|
489
|
+
function registerDeleteTool(resAnno, server, authEnabled) {
|
|
490
|
+
const toolName = nameFor(resAnno.serviceName, resAnno.target, "delete");
|
|
491
|
+
const inputSchema = {};
|
|
492
|
+
// Keys required for deletion
|
|
493
|
+
for (const [k, cdsType] of resAnno.resourceKeys.entries()) {
|
|
494
|
+
inputSchema[k] = (0, utils_2.determineMcpParameterType)(cdsType).describe(`Key ${k}`);
|
|
495
|
+
}
|
|
496
|
+
const keyList = Array.from(resAnno.resourceKeys.keys()).join(", ");
|
|
497
|
+
const hint = resAnno.wrap?.hint ? ` Hint: ${resAnno.wrap?.hint}` : "";
|
|
498
|
+
const desc = `Delete ${resAnno.target} by key(s): ${keyList}. This operation cannot be undone.${hint}`;
|
|
499
|
+
const deleteHandler = async (args) => {
|
|
500
|
+
const CDS = global.cds;
|
|
501
|
+
const { DELETE } = CDS.ql;
|
|
502
|
+
const svc = await resolveServiceInstance(resAnno.serviceName);
|
|
503
|
+
if (!svc) {
|
|
504
|
+
const msg = `Service not found: ${resAnno.serviceName}. Available: ${Object.keys(CDS.services || {}).join(", ")}`;
|
|
505
|
+
logger_1.LOGGER.error(msg);
|
|
506
|
+
return (0, utils_2.toolError)("ERR_MISSING_SERVICE", msg);
|
|
507
|
+
}
|
|
508
|
+
// Extract keys - similar to get/update handlers
|
|
509
|
+
const keys = {};
|
|
510
|
+
for (const [k] of resAnno.resourceKeys.entries()) {
|
|
511
|
+
let provided = args[k];
|
|
512
|
+
if (provided === undefined) {
|
|
513
|
+
// Case-insensitive key matching (like in get handler)
|
|
514
|
+
const alt = Object.entries(args || {}).find(([kk]) => String(kk).toLowerCase() === String(k).toLowerCase());
|
|
515
|
+
if (alt)
|
|
516
|
+
provided = args[alt[0]];
|
|
517
|
+
}
|
|
518
|
+
if (provided === undefined) {
|
|
519
|
+
logger_1.LOGGER.warn(`Delete tool missing required key`, { key: k, toolName });
|
|
520
|
+
return (0, utils_2.toolError)("MISSING_KEY", `Missing key '${k}'`);
|
|
521
|
+
}
|
|
522
|
+
// Coerce numeric strings (like in get handler)
|
|
523
|
+
const raw = provided;
|
|
524
|
+
keys[k] =
|
|
525
|
+
typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw;
|
|
526
|
+
}
|
|
527
|
+
logger_1.LOGGER.debug(`Executing DELETE on ${resAnno.target} with keys`, keys);
|
|
528
|
+
const tx = svc.tx({ user: (0, utils_1.getAccessRights)(authEnabled) });
|
|
529
|
+
try {
|
|
530
|
+
const response = await withTimeout(tx.run(DELETE.from(resAnno.target).where(keys)), TIMEOUT_MS, toolName, async () => {
|
|
531
|
+
try {
|
|
532
|
+
await tx.rollback();
|
|
533
|
+
}
|
|
534
|
+
catch { }
|
|
535
|
+
});
|
|
536
|
+
try {
|
|
537
|
+
await tx.commit();
|
|
538
|
+
}
|
|
539
|
+
catch { }
|
|
540
|
+
return (0, utils_2.asMcpResult)(response ?? { deleted: true });
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
try {
|
|
544
|
+
await tx.rollback();
|
|
545
|
+
}
|
|
546
|
+
catch { }
|
|
547
|
+
const isTimeout = String(error?.message || "").includes("timed out");
|
|
548
|
+
const msg = isTimeout
|
|
549
|
+
? `${toolName} timed out after ${TIMEOUT_MS}ms`
|
|
550
|
+
: `DELETE_FAILED: ${error?.message || String(error)}`;
|
|
551
|
+
logger_1.LOGGER.error(msg, error);
|
|
552
|
+
return (0, utils_2.toolError)(isTimeout ? "TIMEOUT" : "DELETE_FAILED", msg);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
server.registerTool(toolName, { title: toolName, description: desc, inputSchema }, deleteHandler);
|
|
556
|
+
}
|
|
477
557
|
// Helper: compile structured inputs into a CDS query
|
|
478
558
|
// The function translates the validated MCP input into CQN safely,
|
|
479
559
|
// including a basic escape of string literals to avoid invalid syntax.
|
package/lib/mcp/factory.js
CHANGED
|
@@ -24,7 +24,7 @@ function createMcpServer(config, annotations) {
|
|
|
24
24
|
name: config.name,
|
|
25
25
|
version: config.version,
|
|
26
26
|
capabilities: config.capabilities,
|
|
27
|
-
});
|
|
27
|
+
}, { instructions: config.instructions });
|
|
28
28
|
if (!annotations) {
|
|
29
29
|
logger_1.LOGGER.debug("No annotations provided, skipping registration...");
|
|
30
30
|
return server;
|
|
@@ -33,20 +33,26 @@ function createMcpServer(config, annotations) {
|
|
|
33
33
|
const authEnabled = (0, utils_1.isAuthEnabled)(config.auth);
|
|
34
34
|
// Always register discovery tool for better model planning
|
|
35
35
|
(0, describe_model_1.registerDescribeModelTool)(server);
|
|
36
|
+
const accessRights = (0, utils_1.getAccessRights)(authEnabled);
|
|
36
37
|
for (const entry of annotations.values()) {
|
|
37
38
|
if (entry instanceof structures_1.McpToolAnnotation) {
|
|
39
|
+
if (!(0, utils_1.hasToolOperationAccess)(accessRights, entry.restrictions))
|
|
40
|
+
continue;
|
|
38
41
|
(0, tools_1.assignToolToServer)(entry, server, authEnabled);
|
|
39
42
|
continue;
|
|
40
43
|
}
|
|
41
44
|
else if (entry instanceof structures_1.McpResourceAnnotation) {
|
|
42
|
-
(0,
|
|
45
|
+
const accesses = (0, utils_1.getWrapAccesses)(accessRights, entry.restrictions);
|
|
46
|
+
if (accesses.canRead) {
|
|
47
|
+
(0, resources_1.assignResourceToServer)(entry, server, authEnabled);
|
|
48
|
+
}
|
|
43
49
|
// Optionally expose entities as tools based on global/per-entity switches
|
|
44
50
|
const globalWrap = !!config.wrap_entities_to_actions;
|
|
45
51
|
const localWrap = entry.wrap?.tools;
|
|
46
52
|
const enabled = localWrap === true || (localWrap === undefined && globalWrap);
|
|
47
53
|
if (enabled) {
|
|
48
54
|
const modes = config.wrap_entity_modes ?? ["query", "get"];
|
|
49
|
-
(0, entity_tools_1.registerEntityWrappers)(entry, server, authEnabled, modes);
|
|
55
|
+
(0, entity_tools_1.registerEntityWrappers)(entry, server, authEnabled, modes, accesses);
|
|
50
56
|
}
|
|
51
57
|
continue;
|
|
52
58
|
}
|
package/lib/mcp.js
CHANGED
|
@@ -39,8 +39,10 @@ class McpPlugin {
|
|
|
39
39
|
async onBootstrap(app) {
|
|
40
40
|
logger_1.LOGGER.debug("Event received for 'bootstrap'");
|
|
41
41
|
this.expressApp = app;
|
|
42
|
-
this.expressApp.use(express_1.default.json());
|
|
43
|
-
if
|
|
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") {
|
|
44
46
|
(0, utils_2.registerAuthMiddleware)(this.expressApp);
|
|
45
47
|
}
|
|
46
48
|
await this.registerApiEndpoints();
|
package/lib/types.js
ADDED