@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 +34 -25
- package/lib/mcp/describe-model.js +6 -2
- package/lib/mcp/entity-tools.js +98 -21
- package/package.json +3 -3
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
|
|
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
|
|
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
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavdi/cap-mcp",
|
|
3
|
-
"version": "0.9.
|
|
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.
|
|
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.
|
|
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",
|