@gavdi/cap-mcp 0.9.4 → 0.9.7
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 +36 -1
- package/cds-plugin.js +1 -1
- package/lib/annotations/constants.js +2 -0
- package/lib/annotations/parser.js +11 -5
- package/lib/annotations/structures.js +10 -1
- package/lib/config/loader.js +7 -0
- package/lib/logger.js +48 -2
- package/lib/mcp/describe-model.js +103 -0
- package/lib/mcp/entity-tools.js +548 -0
- package/lib/mcp/factory.js +14 -0
- package/lib/mcp/resources.js +18 -3
- package/lib/mcp/session-manager.js +13 -2
- package/lib/mcp/tools.js +10 -18
- package/lib/mcp/utils.js +56 -0
- package/lib/mcp.js +23 -5
- package/package.json +11 -6
- 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
@@ -0,0 +1,548 @@
|
|
1
|
+
"use strict";
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
3
|
+
exports.registerEntityWrappers = registerEntityWrappers;
|
4
|
+
const zod_1 = require("zod");
|
5
|
+
const utils_1 = require("../auth/utils");
|
6
|
+
const logger_1 = require("../logger");
|
7
|
+
const utils_2 = require("./utils");
|
8
|
+
/**
|
9
|
+
* Wraps a promise with a timeout to avoid indefinite hangs in MCP tool calls.
|
10
|
+
* Ensures we always either resolve within the expected time or fail gracefully.
|
11
|
+
*/
|
12
|
+
async function withTimeout(promise, ms, label, onTimeout) {
|
13
|
+
let timeoutId;
|
14
|
+
try {
|
15
|
+
return await Promise.race([
|
16
|
+
promise,
|
17
|
+
new Promise((_, reject) => {
|
18
|
+
timeoutId = setTimeout(async () => {
|
19
|
+
try {
|
20
|
+
await onTimeout?.();
|
21
|
+
}
|
22
|
+
catch { }
|
23
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
24
|
+
}, ms);
|
25
|
+
}),
|
26
|
+
]);
|
27
|
+
}
|
28
|
+
finally {
|
29
|
+
if (timeoutId)
|
30
|
+
clearTimeout(timeoutId);
|
31
|
+
}
|
32
|
+
}
|
33
|
+
/**
|
34
|
+
* Attempts to find a running CAP service instance for the given service name.
|
35
|
+
* - Checks the in-memory services registry first
|
36
|
+
* - Falls back to known service providers (when available)
|
37
|
+
* Note: We deliberately avoid creating new connections here to not duplicate contexts.
|
38
|
+
*/
|
39
|
+
async function resolveServiceInstance(serviceName) {
|
40
|
+
const CDS = global.cds;
|
41
|
+
// Direct lookup (both exact and lowercase variants)
|
42
|
+
let svc = CDS.services?.[serviceName] || CDS.services?.[serviceName.toLowerCase()];
|
43
|
+
if (svc)
|
44
|
+
return svc;
|
45
|
+
// Look through known service providers
|
46
|
+
const providers = (CDS.service && CDS.service.providers) ||
|
47
|
+
(CDS.services && CDS.services.providers) ||
|
48
|
+
[];
|
49
|
+
if (Array.isArray(providers)) {
|
50
|
+
const found = providers.find((p) => p?.definition?.name === serviceName ||
|
51
|
+
p?.name === serviceName ||
|
52
|
+
(typeof p?.path === "string" &&
|
53
|
+
p.path.includes(serviceName.toLowerCase())));
|
54
|
+
if (found)
|
55
|
+
return found;
|
56
|
+
}
|
57
|
+
// Last resort: connect by name
|
58
|
+
// Do not attempt to require/connect another cds instance; rely on app runtime only
|
59
|
+
return undefined;
|
60
|
+
}
|
61
|
+
// NOTE: We use plain entity names (service projection) for queries.
|
62
|
+
const MAX_TOP = 200;
|
63
|
+
const TIMEOUT_MS = 10_000; // Standard timeout for tool calls (ms)
|
64
|
+
/**
|
65
|
+
* Registers CRUD-like MCP tools for an annotated entity (resource).
|
66
|
+
* Modes can be controlled globally via configuration and per-entity via @mcp.wrap.
|
67
|
+
*
|
68
|
+
* Example tool names (naming is explicit for easier LLM usage):
|
69
|
+
* Service_Entity_query, Service_Entity_get, Service_Entity_create, Service_Entity_update
|
70
|
+
*/
|
71
|
+
function registerEntityWrappers(resAnno, server, authEnabled, defaultModes) {
|
72
|
+
const CDS = global.cds;
|
73
|
+
logger_1.LOGGER.debug(`[REGISTRATION TIME] Registering entity wrappers for ${resAnno.serviceName}.${resAnno.target}, available services:`, Object.keys(CDS.services || {}));
|
74
|
+
const modes = resAnno.wrap?.modes ?? defaultModes;
|
75
|
+
if (modes.includes("query")) {
|
76
|
+
registerQueryTool(resAnno, server, authEnabled);
|
77
|
+
}
|
78
|
+
if (modes.includes("get") &&
|
79
|
+
resAnno.resourceKeys &&
|
80
|
+
resAnno.resourceKeys.size > 0) {
|
81
|
+
registerGetTool(resAnno, server, authEnabled);
|
82
|
+
}
|
83
|
+
if (modes.includes("create")) {
|
84
|
+
registerCreateTool(resAnno, server, authEnabled);
|
85
|
+
}
|
86
|
+
if (modes.includes("update") &&
|
87
|
+
resAnno.resourceKeys &&
|
88
|
+
resAnno.resourceKeys.size > 0) {
|
89
|
+
registerUpdateTool(resAnno, server, authEnabled);
|
90
|
+
}
|
91
|
+
}
|
92
|
+
/**
|
93
|
+
* Builds the visible tool name for a given operation mode.
|
94
|
+
* We prefer a descriptive naming scheme that is easy for humans and LLMs:
|
95
|
+
* Service_Entity_mode
|
96
|
+
*/
|
97
|
+
function nameFor(service, entity, suffix) {
|
98
|
+
// Use explicit Service_Entity_suffix naming to match docs/tests
|
99
|
+
const entityName = entity.split(".").pop(); // keep original case
|
100
|
+
const serviceName = service.split(".").pop(); // keep original case
|
101
|
+
return `${serviceName}_${entityName}_${suffix}`;
|
102
|
+
}
|
103
|
+
/**
|
104
|
+
* Registers the list/query tool for an entity.
|
105
|
+
* Supports select/where/orderby/top/skip and simple text search (q).
|
106
|
+
*/
|
107
|
+
function registerQueryTool(resAnno, server, authEnabled) {
|
108
|
+
const toolName = nameFor(resAnno.serviceName, resAnno.target, "query");
|
109
|
+
// Structured input schema for queries with guard for empty property lists
|
110
|
+
const propKeys = Array.from(resAnno.properties.keys());
|
111
|
+
const fieldEnum = (propKeys.length
|
112
|
+
? zod_1.z.enum(propKeys)
|
113
|
+
: zod_1.z
|
114
|
+
.enum(["__dummy__"])
|
115
|
+
.transform(() => "__dummy__"));
|
116
|
+
const inputZod = zod_1.z
|
117
|
+
.object({
|
118
|
+
top: zod_1.z
|
119
|
+
.number()
|
120
|
+
.int()
|
121
|
+
.min(1)
|
122
|
+
.max(MAX_TOP)
|
123
|
+
.default(25)
|
124
|
+
.describe("Rows (default 25)"),
|
125
|
+
skip: zod_1.z.number().int().min(0).default(0).describe("Offset"),
|
126
|
+
select: zod_1.z.array(fieldEnum).optional(),
|
127
|
+
orderby: zod_1.z
|
128
|
+
.array(zod_1.z.object({
|
129
|
+
field: fieldEnum,
|
130
|
+
dir: zod_1.z.enum(["asc", "desc"]).default("asc"),
|
131
|
+
}))
|
132
|
+
.optional(),
|
133
|
+
where: zod_1.z
|
134
|
+
.array(zod_1.z.object({
|
135
|
+
field: fieldEnum,
|
136
|
+
op: zod_1.z.enum([
|
137
|
+
"eq",
|
138
|
+
"ne",
|
139
|
+
"gt",
|
140
|
+
"ge",
|
141
|
+
"lt",
|
142
|
+
"le",
|
143
|
+
"contains",
|
144
|
+
"startswith",
|
145
|
+
"endswith",
|
146
|
+
"in",
|
147
|
+
]),
|
148
|
+
value: zod_1.z.union([
|
149
|
+
zod_1.z.string(),
|
150
|
+
zod_1.z.number(),
|
151
|
+
zod_1.z.boolean(),
|
152
|
+
zod_1.z.array(zod_1.z.union([zod_1.z.string(), zod_1.z.number()])),
|
153
|
+
]),
|
154
|
+
}))
|
155
|
+
.optional(),
|
156
|
+
q: zod_1.z.string().optional().describe("Quick text search"),
|
157
|
+
return: zod_1.z.enum(["rows", "count", "aggregate"]).default("rows").optional(),
|
158
|
+
aggregate: zod_1.z
|
159
|
+
.array(zod_1.z.object({
|
160
|
+
field: fieldEnum,
|
161
|
+
fn: zod_1.z.enum(["sum", "avg", "min", "max", "count"]),
|
162
|
+
}))
|
163
|
+
.optional(),
|
164
|
+
explain: zod_1.z.boolean().optional(),
|
165
|
+
})
|
166
|
+
.strict();
|
167
|
+
const inputSchema = {
|
168
|
+
top: inputZod.shape.top,
|
169
|
+
skip: inputZod.shape.skip,
|
170
|
+
select: inputZod.shape.select,
|
171
|
+
orderby: inputZod.shape.orderby,
|
172
|
+
where: inputZod.shape.where,
|
173
|
+
q: inputZod.shape.q,
|
174
|
+
return: inputZod.shape.return,
|
175
|
+
aggregate: inputZod.shape.aggregate,
|
176
|
+
explain: inputZod.shape.explain,
|
177
|
+
};
|
178
|
+
const hint = resAnno.wrap?.hint ? ` Hint: ${resAnno.wrap?.hint}` : "";
|
179
|
+
const desc = `List ${resAnno.target}. Use structured filters (where), top/skip/orderby/select. For fields & examples call cap_describe_model.${hint}`;
|
180
|
+
const queryHandler = async (rawArgs) => {
|
181
|
+
const parsed = inputZod.safeParse(rawArgs);
|
182
|
+
if (!parsed.success) {
|
183
|
+
return (0, utils_2.toolError)("INVALID_INPUT", "Query arguments failed validation", {
|
184
|
+
issues: parsed.error.issues,
|
185
|
+
});
|
186
|
+
}
|
187
|
+
const args = parsed.data;
|
188
|
+
const CDS = global.cds;
|
189
|
+
logger_1.LOGGER.debug(`[EXECUTION TIME] Query tool: Looking for service: ${resAnno.serviceName}, available services:`, Object.keys(CDS.services || {}));
|
190
|
+
const svc = await resolveServiceInstance(resAnno.serviceName);
|
191
|
+
if (!svc) {
|
192
|
+
const msg = `Service not found: ${resAnno.serviceName}. Available: ${Object.keys(CDS.services || {}).join(", ")}`;
|
193
|
+
logger_1.LOGGER.error(msg);
|
194
|
+
return (0, utils_2.toolError)("ERR_MISSING_SERVICE", msg);
|
195
|
+
}
|
196
|
+
let q;
|
197
|
+
try {
|
198
|
+
q = buildQuery(CDS, args, resAnno, propKeys);
|
199
|
+
}
|
200
|
+
catch (e) {
|
201
|
+
return (0, utils_2.toolError)("FILTER_PARSE_ERROR", e?.message || String(e));
|
202
|
+
}
|
203
|
+
try {
|
204
|
+
const t0 = Date.now();
|
205
|
+
const response = await withTimeout(executeQuery(CDS, svc, args, q), TIMEOUT_MS, toolName);
|
206
|
+
logger_1.LOGGER.debug(`[EXECUTION TIME] Query tool completed: ${toolName} in ${Date.now() - t0}ms`, { resultKind: args.return ?? "rows" });
|
207
|
+
return (0, utils_2.asMcpResult)(args.explain ? { data: response, plan: undefined } : response);
|
208
|
+
}
|
209
|
+
catch (error) {
|
210
|
+
const msg = `QUERY_FAILED: ${error?.message || String(error)}`;
|
211
|
+
logger_1.LOGGER.error(msg, error);
|
212
|
+
return (0, utils_2.toolError)("QUERY_FAILED", msg);
|
213
|
+
}
|
214
|
+
};
|
215
|
+
server.registerTool(toolName, { title: toolName, description: desc, inputSchema }, queryHandler);
|
216
|
+
}
|
217
|
+
/**
|
218
|
+
* Registers the get-by-keys tool for an entity.
|
219
|
+
* Accepts keys either as an object or shorthand (single-key) value.
|
220
|
+
*/
|
221
|
+
function registerGetTool(resAnno, server, authEnabled) {
|
222
|
+
const toolName = nameFor(resAnno.serviceName, resAnno.target, "get");
|
223
|
+
const inputSchema = {};
|
224
|
+
for (const [k, cdsType] of resAnno.resourceKeys.entries()) {
|
225
|
+
inputSchema[k] = (0, utils_2.determineMcpParameterType)(cdsType).describe(`Key ${k}`);
|
226
|
+
}
|
227
|
+
const keyList = Array.from(resAnno.resourceKeys.keys()).join(", ");
|
228
|
+
const hint = resAnno.wrap?.hint ? ` Hint: ${resAnno.wrap?.hint}` : "";
|
229
|
+
const desc = `Get one ${resAnno.target} by key(s): ${keyList}. For fields & examples call cap_describe_model.${hint}`;
|
230
|
+
const getHandler = async (args) => {
|
231
|
+
const startTime = Date.now();
|
232
|
+
const CDS = global.cds;
|
233
|
+
logger_1.LOGGER.debug(`[EXECUTION TIME] Get tool invoked: ${toolName}`, { args });
|
234
|
+
const svc = await resolveServiceInstance(resAnno.serviceName);
|
235
|
+
if (!svc) {
|
236
|
+
const msg = `Service not found: ${resAnno.serviceName}. Available: ${Object.keys(CDS.services || {}).join(", ")}`;
|
237
|
+
logger_1.LOGGER.error(msg);
|
238
|
+
return (0, utils_2.toolError)("ERR_MISSING_SERVICE", msg);
|
239
|
+
}
|
240
|
+
// Normalize single-key shorthand, case-insensitive keys, and value-only payloads
|
241
|
+
let normalizedArgs = args;
|
242
|
+
if (resAnno.resourceKeys.size === 1) {
|
243
|
+
const onlyKey = Array.from(resAnno.resourceKeys.keys())[0];
|
244
|
+
if (normalizedArgs == null ||
|
245
|
+
typeof normalizedArgs !== "object" ||
|
246
|
+
Array.isArray(normalizedArgs)) {
|
247
|
+
normalizedArgs = { [onlyKey]: normalizedArgs };
|
248
|
+
}
|
249
|
+
else if (normalizedArgs[onlyKey] === undefined &&
|
250
|
+
normalizedArgs.value !== undefined) {
|
251
|
+
normalizedArgs[onlyKey] = normalizedArgs.value;
|
252
|
+
}
|
253
|
+
else if (normalizedArgs[onlyKey] === undefined) {
|
254
|
+
const alt = Object.entries(normalizedArgs).find(([kk]) => String(kk).toLowerCase() === String(onlyKey).toLowerCase());
|
255
|
+
if (alt)
|
256
|
+
normalizedArgs[onlyKey] = normalizedArgs[alt[0]];
|
257
|
+
}
|
258
|
+
}
|
259
|
+
const keys = {};
|
260
|
+
for (const [k] of resAnno.resourceKeys.entries()) {
|
261
|
+
let provided = normalizedArgs[k];
|
262
|
+
if (provided === undefined) {
|
263
|
+
const alt = Object.entries(normalizedArgs || {}).find(([kk]) => String(kk).toLowerCase() === String(k).toLowerCase());
|
264
|
+
if (alt)
|
265
|
+
provided = normalizedArgs[alt[0]];
|
266
|
+
}
|
267
|
+
if (provided === undefined) {
|
268
|
+
logger_1.LOGGER.warn(`Get tool missing required key`, { key: k, toolName });
|
269
|
+
return (0, utils_2.toolError)("MISSING_KEY", `Missing key '${k}'`);
|
270
|
+
}
|
271
|
+
const raw = provided;
|
272
|
+
keys[k] =
|
273
|
+
typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw;
|
274
|
+
}
|
275
|
+
logger_1.LOGGER.debug(`Executing READ on ${resAnno.target} with keys`, keys);
|
276
|
+
try {
|
277
|
+
const response = await withTimeout(svc.run(svc.read(resAnno.target, keys)), TIMEOUT_MS, `${toolName}`);
|
278
|
+
logger_1.LOGGER.debug(`[EXECUTION TIME] Get tool completed: ${toolName} in ${Date.now() - startTime}ms`, { found: !!response });
|
279
|
+
return (0, utils_2.asMcpResult)(response ?? null);
|
280
|
+
}
|
281
|
+
catch (error) {
|
282
|
+
const msg = `GET_FAILED: ${error?.message || String(error)}`;
|
283
|
+
logger_1.LOGGER.error(msg, error);
|
284
|
+
return (0, utils_2.toolError)("GET_FAILED", msg);
|
285
|
+
}
|
286
|
+
};
|
287
|
+
server.registerTool(toolName, { title: toolName, description: desc, inputSchema }, getHandler);
|
288
|
+
}
|
289
|
+
/**
|
290
|
+
* Registers the create tool for an entity.
|
291
|
+
* Associations are exposed via <assoc>_ID fields for simplicity.
|
292
|
+
*/
|
293
|
+
function registerCreateTool(resAnno, server, authEnabled) {
|
294
|
+
const toolName = nameFor(resAnno.serviceName, resAnno.target, "create");
|
295
|
+
const inputSchema = {};
|
296
|
+
for (const [propName, cdsType] of resAnno.properties.entries()) {
|
297
|
+
const isAssociation = String(cdsType).toLowerCase().includes("association");
|
298
|
+
if (isAssociation) {
|
299
|
+
// Prefer foreign key input for associations: <assoc>_ID
|
300
|
+
inputSchema[`${propName}_ID`] = zod_1.z
|
301
|
+
.number()
|
302
|
+
.describe(`Foreign key for association ${propName}`)
|
303
|
+
.optional();
|
304
|
+
continue;
|
305
|
+
}
|
306
|
+
inputSchema[propName] = (0, utils_2.determineMcpParameterType)(cdsType)
|
307
|
+
.optional()
|
308
|
+
.describe(`Field ${propName}`);
|
309
|
+
}
|
310
|
+
const hint = resAnno.wrap?.hint ? ` Hint: ${resAnno.wrap?.hint}` : "";
|
311
|
+
const desc = `Create a new ${resAnno.target}. Provide fields; service applies defaults.${hint}`;
|
312
|
+
const createHandler = async (args) => {
|
313
|
+
const CDS = global.cds;
|
314
|
+
const { INSERT } = CDS.ql;
|
315
|
+
const svc = await resolveServiceInstance(resAnno.serviceName);
|
316
|
+
if (!svc) {
|
317
|
+
const msg = `Service not found: ${resAnno.serviceName}. Available: ${Object.keys(CDS.services || {}).join(", ")}`;
|
318
|
+
logger_1.LOGGER.error(msg);
|
319
|
+
return (0, utils_2.toolError)("ERR_MISSING_SERVICE", msg);
|
320
|
+
}
|
321
|
+
// Build data object from provided args, limited to known properties
|
322
|
+
// Normalize payload: prefer *_ID for associations and coerce numeric strings
|
323
|
+
const data = {};
|
324
|
+
for (const [propName, cdsType] of resAnno.properties.entries()) {
|
325
|
+
const isAssociation = String(cdsType)
|
326
|
+
.toLowerCase()
|
327
|
+
.includes("association");
|
328
|
+
if (isAssociation) {
|
329
|
+
const fkName = `${propName}_ID`;
|
330
|
+
if (args[fkName] !== undefined) {
|
331
|
+
const val = args[fkName];
|
332
|
+
data[fkName] =
|
333
|
+
typeof val === "string" && /^\d+$/.test(val) ? Number(val) : val;
|
334
|
+
}
|
335
|
+
continue;
|
336
|
+
}
|
337
|
+
if (args[propName] !== undefined) {
|
338
|
+
const val = args[propName];
|
339
|
+
data[propName] =
|
340
|
+
typeof val === "string" && /^\d+$/.test(val) ? Number(val) : val;
|
341
|
+
}
|
342
|
+
}
|
343
|
+
const tx = svc.tx({ user: (0, utils_1.getAccessRights)(authEnabled) });
|
344
|
+
try {
|
345
|
+
const response = await withTimeout(tx.run(INSERT.into(resAnno.target).entries(data)), TIMEOUT_MS, toolName, async () => {
|
346
|
+
try {
|
347
|
+
await tx.rollback();
|
348
|
+
}
|
349
|
+
catch { }
|
350
|
+
});
|
351
|
+
try {
|
352
|
+
await tx.commit();
|
353
|
+
}
|
354
|
+
catch { }
|
355
|
+
return (0, utils_2.asMcpResult)(response ?? {});
|
356
|
+
}
|
357
|
+
catch (error) {
|
358
|
+
try {
|
359
|
+
await tx.rollback();
|
360
|
+
}
|
361
|
+
catch { }
|
362
|
+
const isTimeout = String(error?.message || "").includes("timed out");
|
363
|
+
const msg = isTimeout
|
364
|
+
? `${toolName} timed out after ${TIMEOUT_MS}ms`
|
365
|
+
: `CREATE_FAILED: ${error?.message || String(error)}`;
|
366
|
+
logger_1.LOGGER.error(msg, error);
|
367
|
+
return (0, utils_2.toolError)(isTimeout ? "TIMEOUT" : "CREATE_FAILED", msg);
|
368
|
+
}
|
369
|
+
};
|
370
|
+
server.registerTool(toolName, { title: toolName, description: desc, inputSchema }, createHandler);
|
371
|
+
}
|
372
|
+
/**
|
373
|
+
* Registers the update tool for an entity.
|
374
|
+
* Keys are required; non-key fields are optional. Associations via <assoc>_ID.
|
375
|
+
*/
|
376
|
+
function registerUpdateTool(resAnno, server, authEnabled) {
|
377
|
+
const toolName = nameFor(resAnno.serviceName, resAnno.target, "update");
|
378
|
+
const inputSchema = {};
|
379
|
+
// Keys required
|
380
|
+
for (const [k, cdsType] of resAnno.resourceKeys.entries()) {
|
381
|
+
inputSchema[k] = (0, utils_2.determineMcpParameterType)(cdsType).describe(`Key ${k}`);
|
382
|
+
}
|
383
|
+
// Other fields optional
|
384
|
+
for (const [propName, cdsType] of resAnno.properties.entries()) {
|
385
|
+
if (resAnno.resourceKeys.has(propName))
|
386
|
+
continue;
|
387
|
+
const isAssociation = String(cdsType).toLowerCase().includes("association");
|
388
|
+
if (isAssociation) {
|
389
|
+
inputSchema[`${propName}_ID`] = zod_1.z
|
390
|
+
.number()
|
391
|
+
.describe(`Foreign key for association ${propName}`)
|
392
|
+
.optional();
|
393
|
+
continue;
|
394
|
+
}
|
395
|
+
inputSchema[propName] = (0, utils_2.determineMcpParameterType)(cdsType)
|
396
|
+
.optional()
|
397
|
+
.describe(`Field ${propName}`);
|
398
|
+
}
|
399
|
+
const keyList = Array.from(resAnno.resourceKeys.keys()).join(", ");
|
400
|
+
const hint = resAnno.wrap?.hint ? ` Hint: ${resAnno.wrap?.hint}` : "";
|
401
|
+
const desc = `Update ${resAnno.target} by key(s): ${keyList}. Provide fields to update.${hint}`;
|
402
|
+
const updateHandler = async (args) => {
|
403
|
+
const CDS = global.cds;
|
404
|
+
const { UPDATE } = CDS.ql;
|
405
|
+
const svc = await resolveServiceInstance(resAnno.serviceName);
|
406
|
+
if (!svc) {
|
407
|
+
const msg = `Service not found: ${resAnno.serviceName}. Available: ${Object.keys(CDS.services || {}).join(", ")}`;
|
408
|
+
logger_1.LOGGER.error(msg);
|
409
|
+
return (0, utils_2.toolError)("ERR_MISSING_SERVICE", msg);
|
410
|
+
}
|
411
|
+
// Extract keys and update fields
|
412
|
+
const keys = {};
|
413
|
+
for (const [k] of resAnno.resourceKeys.entries()) {
|
414
|
+
if (args[k] === undefined) {
|
415
|
+
return {
|
416
|
+
isError: true,
|
417
|
+
content: [{ type: "text", text: `Missing key '${k}'` }],
|
418
|
+
};
|
419
|
+
}
|
420
|
+
keys[k] = args[k];
|
421
|
+
}
|
422
|
+
// Normalize updates: prefer *_ID for associations and coerce numeric strings
|
423
|
+
const updates = {};
|
424
|
+
for (const [propName, cdsType] of resAnno.properties.entries()) {
|
425
|
+
if (resAnno.resourceKeys.has(propName))
|
426
|
+
continue;
|
427
|
+
const isAssociation = String(cdsType)
|
428
|
+
.toLowerCase()
|
429
|
+
.includes("association");
|
430
|
+
if (isAssociation) {
|
431
|
+
const fkName = `${propName}_ID`;
|
432
|
+
if (args[fkName] !== undefined) {
|
433
|
+
const val = args[fkName];
|
434
|
+
updates[fkName] =
|
435
|
+
typeof val === "string" && /^\d+$/.test(val) ? Number(val) : val;
|
436
|
+
}
|
437
|
+
continue;
|
438
|
+
}
|
439
|
+
if (args[propName] !== undefined) {
|
440
|
+
const val = args[propName];
|
441
|
+
updates[propName] =
|
442
|
+
typeof val === "string" && /^\d+$/.test(val) ? Number(val) : val;
|
443
|
+
}
|
444
|
+
}
|
445
|
+
if (Object.keys(updates).length === 0) {
|
446
|
+
return (0, utils_2.toolError)("NO_FIELDS", "No fields provided to update");
|
447
|
+
}
|
448
|
+
const tx = svc.tx({ user: (0, utils_1.getAccessRights)(authEnabled) });
|
449
|
+
try {
|
450
|
+
const response = await withTimeout(tx.run(UPDATE(resAnno.target).set(updates).where(keys)), TIMEOUT_MS, toolName, async () => {
|
451
|
+
try {
|
452
|
+
await tx.rollback();
|
453
|
+
}
|
454
|
+
catch { }
|
455
|
+
});
|
456
|
+
try {
|
457
|
+
await tx.commit();
|
458
|
+
}
|
459
|
+
catch { }
|
460
|
+
return (0, utils_2.asMcpResult)(response ?? {});
|
461
|
+
}
|
462
|
+
catch (error) {
|
463
|
+
try {
|
464
|
+
await tx.rollback();
|
465
|
+
}
|
466
|
+
catch { }
|
467
|
+
const isTimeout = String(error?.message || "").includes("timed out");
|
468
|
+
const msg = isTimeout
|
469
|
+
? `${toolName} timed out after ${TIMEOUT_MS}ms`
|
470
|
+
: `UPDATE_FAILED: ${error?.message || String(error)}`;
|
471
|
+
logger_1.LOGGER.error(msg, error);
|
472
|
+
return (0, utils_2.toolError)(isTimeout ? "TIMEOUT" : "UPDATE_FAILED", msg);
|
473
|
+
}
|
474
|
+
};
|
475
|
+
server.registerTool(toolName, { title: toolName, description: desc, inputSchema }, updateHandler);
|
476
|
+
}
|
477
|
+
// Helper: compile structured inputs into a CDS query
|
478
|
+
// The function translates the validated MCP input into CQN safely,
|
479
|
+
// including a basic escape of string literals to avoid invalid syntax.
|
480
|
+
function buildQuery(CDS, args, resAnno, propKeys) {
|
481
|
+
const { SELECT } = CDS.ql;
|
482
|
+
const limitTop = args.top ?? 25;
|
483
|
+
const limitSkip = args.skip ?? 0;
|
484
|
+
let qy = SELECT.from(resAnno.target).limit(limitTop, limitSkip);
|
485
|
+
if ((propKeys?.length ?? 0) === 0)
|
486
|
+
return qy;
|
487
|
+
if (args.select?.length)
|
488
|
+
qy = qy.columns(...args.select);
|
489
|
+
if (args.orderby?.length) {
|
490
|
+
// Map to CQN-compatible order by fragments
|
491
|
+
const orderFragments = args.orderby.map((o) => `${o.field} ${o.dir}`);
|
492
|
+
qy = qy.orderBy(...orderFragments);
|
493
|
+
}
|
494
|
+
if ((typeof args.q === "string" && args.q.length > 0) || args.where?.length) {
|
495
|
+
const ands = [];
|
496
|
+
if (args.q) {
|
497
|
+
const textFields = Array.from(resAnno.properties.keys()).filter((k) => /string/i.test(String(resAnno.properties.get(k))));
|
498
|
+
const ors = textFields.map((f) => CDS.parse.expr(`contains(${f}, '${String(args.q).replace(/'/g, "''")}')`));
|
499
|
+
if (ors.length)
|
500
|
+
ands.push(CDS.parse.expr(ors.map((x) => `(${x})`).join(" or ")));
|
501
|
+
}
|
502
|
+
for (const c of args.where || []) {
|
503
|
+
const { field, op, value } = c;
|
504
|
+
if (op === "in" && Array.isArray(value)) {
|
505
|
+
const list = value
|
506
|
+
.map((v) => typeof v === "string" ? `'${v.replace(/'/g, "''")}'` : String(v))
|
507
|
+
.join(",");
|
508
|
+
ands.push(CDS.parse.expr(`${field} in (${list})`));
|
509
|
+
continue;
|
510
|
+
}
|
511
|
+
const lit = typeof value === "string"
|
512
|
+
? `'${String(value).replace(/'/g, "''")}'`
|
513
|
+
: String(value);
|
514
|
+
const expr = ["contains", "startswith", "endswith"].includes(op)
|
515
|
+
? `${op}(${field}, ${lit})`
|
516
|
+
: `${field} ${op} ${lit}`;
|
517
|
+
ands.push(CDS.parse.expr(expr));
|
518
|
+
}
|
519
|
+
if (ands.length)
|
520
|
+
qy = qy.where(ands);
|
521
|
+
}
|
522
|
+
return qy;
|
523
|
+
}
|
524
|
+
// Helper: execute query supporting return=count/aggregate
|
525
|
+
// Supports three modes:
|
526
|
+
// - rows (default): returns the selected rows
|
527
|
+
// - count: returns { count: number }
|
528
|
+
// - aggregate: returns aggregation result rows based on provided definitions
|
529
|
+
async function executeQuery(CDS, svc, args, baseQuery) {
|
530
|
+
const { SELECT } = CDS.ql;
|
531
|
+
switch (args.return) {
|
532
|
+
case "count": {
|
533
|
+
const countQuery = SELECT.from(baseQuery.SELECT.from).columns("count(1) as count");
|
534
|
+
const result = await svc.run(countQuery);
|
535
|
+
const row = Array.isArray(result) ? result[0] : result;
|
536
|
+
return { count: row?.count ?? 0 };
|
537
|
+
}
|
538
|
+
case "aggregate": {
|
539
|
+
if (!args.aggregate?.length)
|
540
|
+
return [];
|
541
|
+
const cols = args.aggregate.map((a) => `${a.fn}(${a.field}) as ${a.fn}_${a.field}`);
|
542
|
+
const aggQuery = SELECT.from(baseQuery.SELECT.from).columns(...cols);
|
543
|
+
return svc.run(aggQuery);
|
544
|
+
}
|
545
|
+
default:
|
546
|
+
return svc.run(baseQuery);
|
547
|
+
}
|
548
|
+
}
|
package/lib/mcp/factory.js
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
"use strict";
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
3
3
|
exports.createMcpServer = createMcpServer;
|
4
|
+
// @ts-ignore - MCP SDK types may not be present at compile time in all environments
|
4
5
|
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
5
6
|
const logger_1 = require("../logger");
|
6
7
|
const structures_1 = require("../annotations/structures");
|
@@ -8,6 +9,9 @@ const tools_1 = require("./tools");
|
|
8
9
|
const resources_1 = require("./resources");
|
9
10
|
const prompts_1 = require("./prompts");
|
10
11
|
const utils_1 = require("../auth/utils");
|
12
|
+
// Use relative import without extension for ts-jest resolver compatibility
|
13
|
+
const entity_tools_1 = require("./entity-tools");
|
14
|
+
const describe_model_1 = require("./describe-model");
|
11
15
|
/**
|
12
16
|
* Creates and configures an MCP server instance with the given configuration and annotations
|
13
17
|
* @param config - CAP configuration object
|
@@ -27,6 +31,8 @@ function createMcpServer(config, annotations) {
|
|
27
31
|
}
|
28
32
|
logger_1.LOGGER.debug("Annotations found for server: ", annotations);
|
29
33
|
const authEnabled = (0, utils_1.isAuthEnabled)(config.auth);
|
34
|
+
// Always register discovery tool for better model planning
|
35
|
+
(0, describe_model_1.registerDescribeModelTool)(server);
|
30
36
|
for (const entry of annotations.values()) {
|
31
37
|
if (entry instanceof structures_1.McpToolAnnotation) {
|
32
38
|
(0, tools_1.assignToolToServer)(entry, server, authEnabled);
|
@@ -34,6 +40,14 @@ function createMcpServer(config, annotations) {
|
|
34
40
|
}
|
35
41
|
else if (entry instanceof structures_1.McpResourceAnnotation) {
|
36
42
|
(0, resources_1.assignResourceToServer)(entry, server, authEnabled);
|
43
|
+
// Optionally expose entities as tools based on global/per-entity switches
|
44
|
+
const globalWrap = !!config.wrap_entities_to_actions;
|
45
|
+
const localWrap = entry.wrap?.tools;
|
46
|
+
const enabled = localWrap === true || (localWrap === undefined && globalWrap);
|
47
|
+
if (enabled) {
|
48
|
+
const modes = config.wrap_entity_modes ?? ["query", "get"];
|
49
|
+
(0, entity_tools_1.registerEntityWrappers)(entry, server, authEnabled, modes);
|
50
|
+
}
|
37
51
|
continue;
|
38
52
|
}
|
39
53
|
else if (entry instanceof structures_1.McpPromptAnnotation) {
|
package/lib/mcp/resources.js
CHANGED
@@ -6,9 +6,24 @@ const logger_1 = require("../logger");
|
|
6
6
|
const utils_1 = require("./utils");
|
7
7
|
const validation_1 = require("./validation");
|
8
8
|
const utils_2 = require("../auth/utils");
|
9
|
-
// import cds from "@sap/cds";
|
10
9
|
/* @ts-ignore */
|
11
10
|
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
11
|
+
async function resolveServiceInstance(serviceName) {
|
12
|
+
const CDS = global.cds || cds;
|
13
|
+
let svc = CDS.services?.[serviceName] || CDS.services?.[serviceName.toLowerCase()];
|
14
|
+
if (svc)
|
15
|
+
return svc;
|
16
|
+
const providers = (CDS.service && CDS.service.providers) ||
|
17
|
+
(CDS.services && CDS.services.providers) ||
|
18
|
+
[];
|
19
|
+
if (Array.isArray(providers)) {
|
20
|
+
const found = providers.find((p) => p?.definition?.name === serviceName || p?.name === serviceName);
|
21
|
+
if (found)
|
22
|
+
return found;
|
23
|
+
}
|
24
|
+
// do not connect; rely on served providers only to avoid duplicate cds contexts
|
25
|
+
return undefined;
|
26
|
+
}
|
12
27
|
/**
|
13
28
|
* Registers a CAP entity as an MCP resource with optional OData query support
|
14
29
|
* Creates either static or dynamic resources based on configured functionalities
|
@@ -34,7 +49,7 @@ function assignResourceToServer(model, server, authEnabled) {
|
|
34
49
|
server.registerResource(model.name, template, // Type assertion to bypass strict type checking - necessary due to broken URI parser in the MCP SDK
|
35
50
|
{ title: model.target, description: detailedDescription }, async (uri, variables) => {
|
36
51
|
const queryParameters = variables;
|
37
|
-
const service =
|
52
|
+
const service = await resolveServiceInstance(model.serviceName);
|
38
53
|
if (!service) {
|
39
54
|
logger_1.LOGGER.error(`Invalid service found for service '${model.serviceName}'`);
|
40
55
|
throw new Error(`Invalid service found for service '${model.serviceName}'`);
|
@@ -56,7 +71,7 @@ function assignResourceToServer(model, server, authEnabled) {
|
|
56
71
|
case "filter":
|
57
72
|
// BUG: If filter value is e.g. "filter=1234" the value 1234 will go through
|
58
73
|
const validatedFilter = validator.validateFilter(v);
|
59
|
-
const expression = cds.parse.expr(validatedFilter);
|
74
|
+
const expression = (global.cds || cds).parse.expr(validatedFilter);
|
60
75
|
query.where(expression);
|
61
76
|
continue;
|
62
77
|
case "select":
|
@@ -63,18 +63,29 @@ class McpSessionManager {
|
|
63
63
|
* @returns Configured StreamableHTTPServerTransport instance
|
64
64
|
*/
|
65
65
|
createTransport(server) {
|
66
|
+
// Prefer JSON responses to avoid SSE client compatibility issues in dev/mock
|
67
|
+
const enableJson = (0, env_sanitizer_1.getSafeEnvVar)("MCP_ENABLE_JSON", "true") === "true" ||
|
68
|
+
(0, env_sanitizer_1.isTestEnvironment)();
|
66
69
|
const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
67
70
|
sessionIdGenerator: () => (0, crypto_1.randomUUID)(),
|
68
|
-
enableJsonResponse:
|
71
|
+
enableJsonResponse: enableJson,
|
69
72
|
onsessioninitialized: (sid) => {
|
70
73
|
logger_1.LOGGER.debug("Session initialized with ID: ", sid);
|
74
|
+
logger_1.LOGGER.debug("Transport mode", { enableJsonResponse: enableJson });
|
71
75
|
this.sessions.set(sid, {
|
72
76
|
server: server,
|
73
77
|
transport: transport,
|
74
78
|
});
|
75
79
|
},
|
76
80
|
});
|
77
|
-
|
81
|
+
// In JSON response mode, HTTP connections are short-lived per request.
|
82
|
+
// Closing the underlying connection does NOT mean the MCP session is over.
|
83
|
+
// Avoid deleting the session on close when enableJson is true.
|
84
|
+
transport.onclose = () => {
|
85
|
+
if (!enableJson) {
|
86
|
+
this.onCloseSession(transport);
|
87
|
+
}
|
88
|
+
};
|
78
89
|
return transport;
|
79
90
|
}
|
80
91
|
/**
|