@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 +1 -18
- package/lib/auth/handler.js +1 -13
- package/lib/auth/utils.js +51 -79
- package/lib/mcp/describe-model.js +6 -2
- package/lib/mcp/entity-tools.js +98 -21
- package/lib/mcp.js +1 -3
- package/package.json +2 -2
- package/lib/.DS_Store +0 -0
- package/lib/annotations.js +0 -257
- package/lib/auth/adapter.js +0 -2
- package/lib/auth/mock.js +0 -2
- package/lib/auth/types.js +0 -2
- package/lib/mcp/customResourceTemplate.js +0 -156
- package/lib/types.js +0 -2
- package/lib/utils.js +0 -136
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
|
package/lib/auth/handler.js
CHANGED
|
@@ -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
|
-
|
|
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"); //
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
173
|
-
return; // Don't throw error, just skip OAuth proxy
|
|
185
|
+
throw new Error("Invalid security credentials");
|
|
174
186
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
package/lib/mcp/entity-tools.js
CHANGED
|
@@ -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
|
|
119
|
-
const
|
|
120
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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,
|
|
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
|
|
579
|
-
|
|
580
|
-
|
|
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(`${
|
|
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}(${
|
|
596
|
-
: `${
|
|
668
|
+
? `${op}(${actualField}, ${lit})`
|
|
669
|
+
: `${actualField} ${cdsOp} ${lit}`;
|
|
597
670
|
ands.push(CDS.parse.expr(expr));
|
|
598
671
|
}
|
|
599
|
-
if (ands.length)
|
|
600
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
package/lib/annotations.js
DELETED
|
@@ -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
|
-
}
|
package/lib/auth/adapter.js
DELETED
package/lib/auth/mock.js
DELETED
package/lib/auth/types.js
DELETED
|
@@ -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
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
|
-
}
|