@gavdi/cap-mcp 0.9.8 → 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 CHANGED
@@ -3,7 +3,7 @@
3
3
  > This implementation is based on the Model Context Protocol (MCP) put forward by Anthropic.
4
4
  > For more information on MCP, please have a look at their [official documentation.](https://modelcontextprotocol.io/introduction)
5
5
 
6
- > 🔧 **In active development - 1.0 release scheduled for Summer 2025**
6
+ > 🔧 **In active development - 1.0 release scheduled for September 2025**
7
7
 
8
8
  # CAP-MCP Plugin
9
9
 
@@ -15,7 +15,7 @@ Transform your CAP OData services into AI-accessible resources, tools, and promp
15
15
  The Model Context Protocol bridges the gap between your enterprise data and AI agents.
16
16
  By integrating MCP with your CAP applications, you unlock:
17
17
 
18
- - **AI-Native Data Access**: Your CAP services become directly accessible to AI agents like Claude, enabling natural language queries against your business data
18
+ - **AI-Native Data Access**: Your CAP services become directly accessible to MCP enabled AI agents like Claude, enabling natural language queries against your business data
19
19
  - **Enterprise Integration**: Seamlessly connect AI tools to your SAP systems, databases, and business logic
20
20
  - **Intelligent Automation**: Enable AI agents to perform complex business operations by combining multiple CAP service calls
21
21
  - **Developer Productivity**: Allow AI assistants to help developers understand, query, and work with your CAP data models
@@ -23,19 +23,11 @@ By integrating MCP with your CAP applications, you unlock:
23
23
 
24
24
  ## ⚠️ Development Status
25
25
 
26
- **This plugin is currently in active development (v0.9.2) and approaching production readiness.**
26
+ **This plugin is currently in active development and approaching production readiness.**
27
27
  APIs and annotations may change in future releases. Authentication and security features are implemented and tested.
28
28
 
29
29
  Version 1.0 of the plugin is scheduled for release in Summer 2025 after final stability testing and documentation completion.
30
30
 
31
- ## 📦 Installation
32
-
33
- ```bash
34
- npm install @gavdi/cap-mcp
35
- ```
36
-
37
- The plugin follows CAP's standard plugin architecture and will automatically integrate with your CAP application.
38
-
39
31
  ## 🚀 Quick Setup
40
32
 
41
33
  ### Prerequisites
@@ -51,6 +43,8 @@ The plugin follows CAP's standard plugin architecture and will automatically int
51
43
  npm install @gavdi/cap-mcp
52
44
  ```
53
45
 
46
+ The plugin follows CAP's standard plugin architecture and will automatically integrate with your CAP application upon installation.
47
+
54
48
  ### Step 2: Configure Your CAP Application
55
49
 
56
50
  Add MCP configuration to your `package.json`:
@@ -83,7 +77,7 @@ service CatalogService {
83
77
  resource: ['filter', 'orderby', 'select', 'top', 'skip']
84
78
  }
85
79
  entity Books as projection on my.Books;
86
-
80
+
87
81
  // Optionally expose Books as tools for LLMs (query/get enabled by default config)
88
82
  annotate CatalogService.Books with @mcp.wrap: {
89
83
  tools: true,
@@ -135,17 +129,6 @@ This plugin transforms your annotated CAP services into a fully functional MCP s
135
129
  - Start demo app: `npm run mock`
136
130
  - Inspector: `npx @modelcontextprotocol/inspector`
137
131
 
138
- ### New wrapper tools
139
-
140
- When `wrap_entities_to_actions` is enabled (globally or via `@mcp.wrap.tools: true`), you will see tools named like:
141
-
142
- - `CatalogService_Books_query`
143
- - `CatalogService_Books_get`
144
- - `CatalogService_Books_create` (if enabled)
145
- - `CatalogService_Books_update` (if enabled)
146
-
147
- Each tool includes a description with fields and OData notes to guide the model. You can add `@mcp.wrap.hint` per entity to enrich descriptions for LLMs.
148
-
149
132
  ### Bruno collection
150
133
 
151
134
  The `bruno/` folder contains HTTP requests for the MCP endpoint (handy for local manual testing using Bruno or any HTTP client). You may add calls for `tools/list` and `tools/call` to exercise the new wrapper tools.
@@ -197,6 +180,33 @@ service CatalogService {
197
180
  - **Dynamic Filtering**: Complex filter expressions using OData syntax
198
181
  - **Flexible Selection**: Choose specific fields and sort orders
199
182
 
183
+ ### Wrapper tools
184
+
185
+ When `wrap_entities_to_actions` is enabled (globally or via `@mcp.wrap.tools: true`), you will see tools named like:
186
+
187
+ - `CatalogService_Books_query`
188
+ - `CatalogService_Books_get`
189
+ - `CatalogService_Books_create` (if enabled)
190
+ - `CatalogService_Books_update` (if enabled)
191
+
192
+ Each tool includes a description with fields and OData notes to guide the model. You can add `@mcp.wrap.hint` per entity to enrich descriptions for LLMs.
193
+
194
+ Example:
195
+
196
+ ```cds
197
+ // Wrap Books entity as tools for query/get/create/update (demo)
198
+ annotate CatalogService.Books with @mcp.wrap: {
199
+ tools: true,
200
+ modes: [
201
+ 'query',
202
+ 'get',
203
+ 'create',
204
+ 'update'
205
+ ],
206
+ hint : 'Use for read and write demo operations'
207
+ };
208
+ ```
209
+
200
210
  ### Tool Annotations
201
211
 
202
212
  Convert CAP functions and actions into executable AI tools:
@@ -277,6 +287,7 @@ Configure the MCP plugin through your CAP application's `package.json` or `.cdsr
277
287
  | `name` | string | package.json name | MCP server name |
278
288
  | `version` | string | package.json version | MCP server version |
279
289
  | `auth` | `"inherit"` \| `"none"` | `"inherit"` | Authentication mode |
290
+ | `instructions` | string | `null` | MCP server instructions for agents |
280
291
  | `capabilities.resources.listChanged` | boolean | `true` | Enable resource list change notifications |
281
292
  | `capabilities.resources.subscribe` | boolean | `false` | Enable resource subscriptions |
282
293
  | `capabilities.tools.listChanged` | boolean | `true` | Enable tool list change notifications |
@@ -540,8 +551,6 @@ npm test -- --testPathPattern=integration
540
551
 
541
552
  ### Known Limitations
542
553
  - **SDK Bug**: Dynamic resource queries require all query parameters due to `@modelcontextprotocol/sdk` RFC template string issue
543
- - **Authentication Inheritance**: MCP authentication is tightly coupled to CAP's authentication system
544
- - **Session Cleanup**: MCP sessions are cleaned up on HTTP connection close, not on explicit disconnect
545
554
 
546
555
  ### Performance Considerations
547
556
  - **Large Datasets**: Use `resource: ['top']` or similar constraints for entities with many records
@@ -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
- const shortFields = elements.slice(0, 5).map((e) => e.name);
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; use *_get with keys for a single record; use *_create/*_update only if enabled and necessary.",
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,
@@ -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 propKeys = Array.from(resAnno.properties.keys());
119
- const fieldEnum = (propKeys.length
120
- ? zod_1.z.enum(propKeys)
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.array(fieldEnum).optional(),
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: fieldEnum,
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: fieldEnum,
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: fieldEnum,
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 = `List ${resAnno.target}. Use structured filters (where), top/skip/orderby/select. For fields & examples call cap_describe_model.${hint}`;
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, propKeys);
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 ors = textFields.map((f) => CDS.parse.expr(`contains(${f}, '${String(args.q).replace(/'/g, "''")}')`));
579
- if (ors.length)
580
- ands.push(CDS.parse.expr(ors.map((x) => `(${x})`).join(" or ")));
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(`${field} in (${list})`));
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}(${field}, ${lit})`
596
- : `${field} ${op} ${lit}`;
668
+ ? `${op}(${actualField}, ${lit})`
669
+ : `${actualField} ${cdsOp} ${lit}`;
597
670
  ands.push(CDS.parse.expr(expr));
598
671
  }
599
- if (ands.length)
600
- qy = qy.where(ands);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavdi/cap-mcp",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "description": "MCP Pluging for CAP",
5
5
  "keywords": [
6
6
  "MCP",
@@ -41,12 +41,12 @@
41
41
  "express": "^4"
42
42
  },
43
43
  "dependencies": {
44
- "@modelcontextprotocol/sdk": "^1.17.1",
44
+ "@modelcontextprotocol/sdk": "^1.17.3",
45
45
  "zod": "^3.25.67",
46
46
  "zod-to-json-schema": "^3.24.5"
47
47
  },
48
48
  "devDependencies": {
49
- "@cap-js/cds-types": "^0.12.0",
49
+ "@cap-js/cds-types": "^0.13.0",
50
50
  "@release-it/conventional-changelog": "^10.0.1",
51
51
  "@types/express": "^5.0.3",
52
52
  "@types/jest": "^30.0.0",