@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.
@@ -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
+ }
@@ -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) {
@@ -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 = cds.services[model.serviceName];
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: (0, env_sanitizer_1.isTestEnvironment)(),
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
- transport.onclose = () => this.onCloseSession(transport);
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
  /**