@gavdi/cap-mcp 0.9.8 → 0.9.9-alpha.3
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 +52 -26
- package/lib/.DS_Store +0 -0
- package/lib/annotations.js +257 -0
- package/lib/auth/adapter.js +2 -0
- package/lib/auth/handler.js +13 -1
- package/lib/auth/mock.js +2 -0
- package/lib/auth/types.js +2 -0
- package/lib/auth/utils.js +79 -51
- package/lib/mcp/customResourceTemplate.js +156 -0
- package/lib/mcp.js +3 -1
- package/lib/types.js +2 -0
- package/lib/utils.js +136 -0
- 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 |
|
|
@@ -539,9 +550,24 @@ npm test -- --testPathPattern=integration
|
|
|
539
550
|
## 🚨 Performance & Limitations
|
|
540
551
|
|
|
541
552
|
### Known Limitations
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
553
|
+
|
|
554
|
+
#### No Interactive Authentication Support
|
|
555
|
+
**The plugin currently does NOT support interactive OAuth flows** that allow end-users to log in through MCP clients like Claude Desktop, Cursor, or other consumer MCP applications.
|
|
556
|
+
|
|
557
|
+
**What this means:**
|
|
558
|
+
- ✅ Works with custom MCP clients that can inject pre-obtained bearer tokens
|
|
559
|
+
- ✅ Works in development with `dummy` authentication
|
|
560
|
+
- ❌ **Does NOT work with Claude Desktop, Cursor, or similar clients expecting OAuth login flows**
|
|
561
|
+
- ❌ End-users cannot authenticate interactively when connecting
|
|
562
|
+
|
|
563
|
+
**Technical Context:** This limitation exists due to architectural constraints in the current Model Context Protocol SDK. The MCP community is actively working on a solution that would enable proper interactive authentication flows, but no timeline has been announced. This is expected to be resolved in the second half of 2025.
|
|
564
|
+
|
|
565
|
+
**Workarounds:**
|
|
566
|
+
- **For Development**: Use `"auth": { "kind": "dummy" }` in your CAP configuration
|
|
567
|
+
- **For Production**: Custom MCP clients must obtain valid bearer tokens through your CAP application's existing authentication flow and include them in requests as `Authorization: Bearer <token>`
|
|
568
|
+
|
|
569
|
+
#### SDK Bug
|
|
570
|
+
- **Dynamic Resource Queries**: Require all query parameters due to `@modelcontextprotocol/sdk` RFC template string issue
|
|
545
571
|
|
|
546
572
|
### Performance Considerations
|
|
547
573
|
- **Large Datasets**: Use `resource: ['top']` or similar constraints for entities with many records
|
package/lib/.DS_Store
ADDED
|
Binary file
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.McpPromptAnnotation = exports.McpToolAnnotation = exports.McpResourceAnnotation = exports.McpAnnotation = exports.McpAnnotations = exports.McpAnnotationKey = void 0;
|
|
4
|
+
exports.parseAnnotations = parseAnnotations;
|
|
5
|
+
const utils_1 = require("./utils");
|
|
6
|
+
const DEFAULT_ALL_RESOURCE_OPTIONS = new Set([
|
|
7
|
+
"filter",
|
|
8
|
+
"sort",
|
|
9
|
+
"top",
|
|
10
|
+
"skip",
|
|
11
|
+
"select",
|
|
12
|
+
]);
|
|
13
|
+
exports.McpAnnotationKey = "@mcp";
|
|
14
|
+
exports.McpAnnotations = {
|
|
15
|
+
// Resource annotations for MCP
|
|
16
|
+
MCP_RESOURCE: "@mcp.resource",
|
|
17
|
+
// Tool annotations for MCP
|
|
18
|
+
MCP_TOOL_NAME: "@mcp.tool.name",
|
|
19
|
+
MCP_TOOL_DESCRIPTION: "@mcp.tool.description",
|
|
20
|
+
// Prompt annotations for MCP
|
|
21
|
+
MCP_PROMPT: "@mcp.prompt",
|
|
22
|
+
};
|
|
23
|
+
class McpAnnotation {
|
|
24
|
+
_target;
|
|
25
|
+
_serviceName;
|
|
26
|
+
constructor(target, serviceName) {
|
|
27
|
+
this._target = target;
|
|
28
|
+
this._serviceName = serviceName;
|
|
29
|
+
}
|
|
30
|
+
get target() {
|
|
31
|
+
return this._target;
|
|
32
|
+
}
|
|
33
|
+
get serviceName() {
|
|
34
|
+
return this._serviceName;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
exports.McpAnnotation = McpAnnotation;
|
|
38
|
+
class McpResourceAnnotation extends McpAnnotation {
|
|
39
|
+
_includeAll;
|
|
40
|
+
_functionalities;
|
|
41
|
+
_properties;
|
|
42
|
+
constructor(target, serviceName, includeAll, functionalities, properties) {
|
|
43
|
+
super(target, serviceName);
|
|
44
|
+
this._includeAll = includeAll;
|
|
45
|
+
this._functionalities = functionalities;
|
|
46
|
+
this._properties = properties;
|
|
47
|
+
}
|
|
48
|
+
get includeAll() {
|
|
49
|
+
return this._includeAll;
|
|
50
|
+
}
|
|
51
|
+
get functionalities() {
|
|
52
|
+
return this._functionalities;
|
|
53
|
+
}
|
|
54
|
+
get properties() {
|
|
55
|
+
return this._properties;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.McpResourceAnnotation = McpResourceAnnotation;
|
|
59
|
+
class McpToolAnnotation extends McpAnnotation {
|
|
60
|
+
_name;
|
|
61
|
+
_description;
|
|
62
|
+
_parameters;
|
|
63
|
+
_entityKey;
|
|
64
|
+
_operationKind;
|
|
65
|
+
_keyTypeMap;
|
|
66
|
+
constructor(name, description, operation, serviceName, parameters, entityKey, operationKind, keyTypeMap) {
|
|
67
|
+
super(operation, serviceName);
|
|
68
|
+
this._name = name;
|
|
69
|
+
this._description = description;
|
|
70
|
+
this._parameters = parameters;
|
|
71
|
+
this._entityKey = entityKey;
|
|
72
|
+
this._operationKind = operationKind;
|
|
73
|
+
this._keyTypeMap = keyTypeMap;
|
|
74
|
+
}
|
|
75
|
+
get name() {
|
|
76
|
+
return this._name;
|
|
77
|
+
}
|
|
78
|
+
get description() {
|
|
79
|
+
return this._description;
|
|
80
|
+
}
|
|
81
|
+
get parameters() {
|
|
82
|
+
return this._parameters;
|
|
83
|
+
}
|
|
84
|
+
get entityKey() {
|
|
85
|
+
return this._entityKey;
|
|
86
|
+
}
|
|
87
|
+
get operationKind() {
|
|
88
|
+
return this._operationKind;
|
|
89
|
+
}
|
|
90
|
+
get keyTypeMap() {
|
|
91
|
+
return this._keyTypeMap;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
exports.McpToolAnnotation = McpToolAnnotation;
|
|
95
|
+
class McpPromptAnnotation extends McpAnnotation {
|
|
96
|
+
_name;
|
|
97
|
+
_template;
|
|
98
|
+
constructor(target, serviceName, name, template) {
|
|
99
|
+
super(target, serviceName);
|
|
100
|
+
this._name = name;
|
|
101
|
+
this._template = template;
|
|
102
|
+
}
|
|
103
|
+
get name() {
|
|
104
|
+
return this._name;
|
|
105
|
+
}
|
|
106
|
+
get template() {
|
|
107
|
+
return this._template;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
exports.McpPromptAnnotation = McpPromptAnnotation;
|
|
111
|
+
function parseAnnotations(services) {
|
|
112
|
+
const annotations = [];
|
|
113
|
+
for (const serviceName of Object.keys(services)) {
|
|
114
|
+
const srv = services[serviceName];
|
|
115
|
+
if (srv.name === "CatalogService") {
|
|
116
|
+
utils_1.LOGGER.debug("SERVICE: ", srv.model.definitions);
|
|
117
|
+
}
|
|
118
|
+
const entities = srv.entities;
|
|
119
|
+
const operations = srv.operations; // Refers to action and function imports
|
|
120
|
+
// Find entities
|
|
121
|
+
for (const entityName of Object.keys(entities)) {
|
|
122
|
+
const target = entities[entityName];
|
|
123
|
+
const res = findEntityAnnotations(target, entityName, srv);
|
|
124
|
+
if (target.actions) {
|
|
125
|
+
const bound = parseBoundOperations(target.actions, entityName, target, srv);
|
|
126
|
+
if (bound && bound.length > 0) {
|
|
127
|
+
annotations.push(...bound);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!res)
|
|
131
|
+
continue;
|
|
132
|
+
annotations.push(res);
|
|
133
|
+
}
|
|
134
|
+
// Find operations
|
|
135
|
+
for (const operationName of Object.keys(operations)) {
|
|
136
|
+
const op = operations[operationName];
|
|
137
|
+
const res = findOperationAnnotations(op, operationName, srv);
|
|
138
|
+
if (!res)
|
|
139
|
+
continue;
|
|
140
|
+
annotations.push(res);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const result = formatAnnotations(annotations);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
function formatAnnotations(annotationList) {
|
|
147
|
+
const result = new Map();
|
|
148
|
+
for (const annotation of annotationList) {
|
|
149
|
+
if (annotation.operation) {
|
|
150
|
+
if (!annotation.annotations[exports.McpAnnotations.MCP_TOOL_NAME] ||
|
|
151
|
+
!annotation.annotations[exports.McpAnnotations.MCP_TOOL_DESCRIPTION]) {
|
|
152
|
+
utils_1.LOGGER.error(`Invalid annotation found for operation`, annotation);
|
|
153
|
+
throw new Error(`Invalid annotations for operation '${annotation.operation}'`);
|
|
154
|
+
}
|
|
155
|
+
else if (typeof annotation.annotations[exports.McpAnnotations.MCP_TOOL_NAME] !==
|
|
156
|
+
"string" ||
|
|
157
|
+
typeof annotation.annotations[exports.McpAnnotations.MCP_TOOL_DESCRIPTION] !==
|
|
158
|
+
"string") {
|
|
159
|
+
utils_1.LOGGER.error("Invalid data for annotations", annotation);
|
|
160
|
+
throw new Error(`Invalid annotation data for operation '${annotation.operation}'`);
|
|
161
|
+
}
|
|
162
|
+
const entry = new McpToolAnnotation(annotation.annotations[exports.McpAnnotations.MCP_TOOL_NAME], annotation.annotations[exports.McpAnnotations.MCP_TOOL_DESCRIPTION], annotation.operation, annotation.serviceName, mapOperationInput(annotation.context), // TODO: Parse the parameters from the context and place them in the class
|
|
163
|
+
annotation.entityKey, annotation.operationKind, annotation.keyTypeMap);
|
|
164
|
+
result.set(entry.target, entry);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!annotation.entityKey) {
|
|
168
|
+
utils_1.LOGGER.error("Invalid entry", annotation);
|
|
169
|
+
throw new Error(`Invalid annotated entry found with no target`);
|
|
170
|
+
}
|
|
171
|
+
if (!annotation.annotations[exports.McpAnnotations.MCP_RESOURCE]) {
|
|
172
|
+
utils_1.LOGGER.error("No valid annotations found for entry", annotation);
|
|
173
|
+
throw new Error(`Invalid annotations for entry target: '${annotation.entityKey}'`);
|
|
174
|
+
}
|
|
175
|
+
const includeAll = annotation.annotations[exports.McpAnnotations.MCP_RESOURCE] === true;
|
|
176
|
+
const functionalities = Array.isArray(annotation.annotations[exports.McpAnnotations.MCP_RESOURCE])
|
|
177
|
+
? new Set(annotation.annotations[exports.McpAnnotations.MCP_RESOURCE])
|
|
178
|
+
: DEFAULT_ALL_RESOURCE_OPTIONS;
|
|
179
|
+
const entry = new McpResourceAnnotation(annotation.entityKey, annotation.serviceName, includeAll, functionalities, (0, utils_1.parseEntityElements)(annotation.context));
|
|
180
|
+
result.set(entry.target, entry);
|
|
181
|
+
}
|
|
182
|
+
utils_1.LOGGER.debug("Formatted annotations", result);
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
function findEntityAnnotations(entry, entityKey, service) {
|
|
186
|
+
const annotations = findAnnotations(entry);
|
|
187
|
+
return Object.keys(annotations).length > 0
|
|
188
|
+
? {
|
|
189
|
+
serviceName: service.name,
|
|
190
|
+
annotations: annotations,
|
|
191
|
+
entityKey: entityKey,
|
|
192
|
+
context: entry,
|
|
193
|
+
}
|
|
194
|
+
: undefined;
|
|
195
|
+
}
|
|
196
|
+
function findOperationAnnotations(operation, operationName, service) {
|
|
197
|
+
const annotations = findAnnotations(operation);
|
|
198
|
+
return Object.keys(annotations).length > 0
|
|
199
|
+
? {
|
|
200
|
+
serviceName: service.name,
|
|
201
|
+
annotations: annotations,
|
|
202
|
+
operation: operationName,
|
|
203
|
+
operationKind: operation.kind,
|
|
204
|
+
context: operation,
|
|
205
|
+
}
|
|
206
|
+
: undefined;
|
|
207
|
+
}
|
|
208
|
+
function parseBoundOperations(operations, entityKey, entity, service) {
|
|
209
|
+
const res = new Array();
|
|
210
|
+
for (const [operationName, operation] of Object.entries(operations)) {
|
|
211
|
+
const annotation = findBoundOperationAnnotations(operation, operationName, entityKey, service);
|
|
212
|
+
if (!annotation)
|
|
213
|
+
continue;
|
|
214
|
+
annotation.keyTypeMap = new Map();
|
|
215
|
+
for (const [k, v] of Object.entries(entity.keys)) {
|
|
216
|
+
if (!v.type) {
|
|
217
|
+
utils_1.LOGGER.error("Invalid key type", k);
|
|
218
|
+
throw new Error("Invalid key type found for bound operation");
|
|
219
|
+
}
|
|
220
|
+
annotation.keyTypeMap.set(k, v.type.replace("cds.", ""));
|
|
221
|
+
}
|
|
222
|
+
res.push(annotation);
|
|
223
|
+
}
|
|
224
|
+
return res;
|
|
225
|
+
}
|
|
226
|
+
function findBoundOperationAnnotations(operation, operationName, entityKey, service) {
|
|
227
|
+
const annotations = findAnnotations(operation);
|
|
228
|
+
return Object.keys(annotations).length > 0
|
|
229
|
+
? {
|
|
230
|
+
serviceName: service.name,
|
|
231
|
+
annotations: annotations,
|
|
232
|
+
operation: operationName,
|
|
233
|
+
operationKind: operation.kind,
|
|
234
|
+
entityKey: entityKey,
|
|
235
|
+
context: operation,
|
|
236
|
+
}
|
|
237
|
+
: undefined;
|
|
238
|
+
}
|
|
239
|
+
function findAnnotations(entry) {
|
|
240
|
+
const annotations = {};
|
|
241
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
242
|
+
if (!k.includes(exports.McpAnnotationKey))
|
|
243
|
+
continue;
|
|
244
|
+
annotations[k] = v;
|
|
245
|
+
}
|
|
246
|
+
return annotations;
|
|
247
|
+
}
|
|
248
|
+
function mapOperationInput(ctx) {
|
|
249
|
+
const params = ctx["params"];
|
|
250
|
+
if (!params)
|
|
251
|
+
return undefined;
|
|
252
|
+
const result = new Map();
|
|
253
|
+
for (const [k, v] of Object.entries(params)) {
|
|
254
|
+
result.set(k, v.type.replace("cds.", ""));
|
|
255
|
+
}
|
|
256
|
+
return result.size > 0 ? result : undefined;
|
|
257
|
+
}
|
package/lib/auth/handler.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.authHandlerFactory = authHandlerFactory;
|
|
4
4
|
exports.errorHandlerFactory = errorHandlerFactory;
|
|
5
|
+
const logger_1 = require("../logger");
|
|
5
6
|
/** JSON-RPC 2.0 error code for unauthorized requests */
|
|
6
7
|
const RPC_UNAUTHORIZED = 10;
|
|
8
|
+
/** HTTP Authenticate header **/
|
|
9
|
+
const WWW_AUTHENTICATE = "WWW-Authenticate";
|
|
7
10
|
/* @ts-ignore */
|
|
8
11
|
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
|
9
12
|
/**
|
|
@@ -31,9 +34,15 @@ const cds = global.cds || require("@sap/cds"); // This is a work around for miss
|
|
|
31
34
|
* @throws {500} When CAP context is not properly loaded
|
|
32
35
|
*/
|
|
33
36
|
function authHandlerFactory() {
|
|
34
|
-
const authKind = cds.env.requires.auth.kind;
|
|
35
37
|
return (req, res, next) => {
|
|
38
|
+
const auth = cds.env.requires.auth;
|
|
39
|
+
const authKind = auth.kind;
|
|
40
|
+
const credentials = auth.credentials;
|
|
36
41
|
if (!req.headers.authorization && authKind !== "dummy") {
|
|
42
|
+
logger_1.LOGGER.warn("No valid authorization header provided");
|
|
43
|
+
// We need to return a WWW-Authenticate response header here with .well-known metadata
|
|
44
|
+
// Otherwise the MCP client will not be able to figure out the auth flow
|
|
45
|
+
res.setHeader(WWW_AUTHENTICATE, `Bearer error='"invalid_token", resource_metadata="${credentials?.url}/.well-known/oauth-protected-resource"`);
|
|
37
46
|
res.status(401).json({
|
|
38
47
|
jsonrpc: "2.0",
|
|
39
48
|
error: {
|
|
@@ -58,6 +67,9 @@ function authHandlerFactory() {
|
|
|
58
67
|
}
|
|
59
68
|
const user = ctx.user;
|
|
60
69
|
if (!user || user === cds.User.anonymous) {
|
|
70
|
+
// We need to return a WWW-Authenticate response header here with .well-known metadata
|
|
71
|
+
// Otherwise the MCP client will not be able to figure out the auth flow
|
|
72
|
+
res.setHeader(WWW_AUTHENTICATE, `Bearer error='"invalid_token", resource_metadata="${credentials?.url}/.well-known/oauth-protected-resource"`);
|
|
61
73
|
res.status(401).json({
|
|
62
74
|
jsonrpc: "2.0",
|
|
63
75
|
error: {
|
package/lib/auth/mock.js
ADDED
package/lib/auth/utils.js
CHANGED
|
@@ -6,30 +6,10 @@ exports.registerAuthMiddleware = registerAuthMiddleware;
|
|
|
6
6
|
exports.hasToolOperationAccess = hasToolOperationAccess;
|
|
7
7
|
exports.getWrapAccesses = getWrapAccesses;
|
|
8
8
|
const handler_1 = require("./handler");
|
|
9
|
-
const proxyProvider_js_1 = require("@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js");
|
|
10
9
|
const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js");
|
|
11
|
-
|
|
12
|
-
* @fileoverview Authentication utilities for MCP-CAP integration.
|
|
13
|
-
*
|
|
14
|
-
* This module provides utilities for integrating CAP authentication with MCP servers.
|
|
15
|
-
* It supports all standard CAP authentication types and provides functions for:
|
|
16
|
-
* - Determining authentication status
|
|
17
|
-
* - Managing user access rights
|
|
18
|
-
* - Registering authentication middleware
|
|
19
|
-
*
|
|
20
|
-
* Supported CAP authentication types:
|
|
21
|
-
* - 'dummy': No authentication (privileged access)
|
|
22
|
-
* - 'mocked': Mock users with predefined credentials
|
|
23
|
-
* - 'basic': HTTP Basic Authentication
|
|
24
|
-
* - 'jwt': Generic JWT token validation
|
|
25
|
-
* - 'xsuaa': SAP BTP XSUAA OAuth2/JWT authentication
|
|
26
|
-
* - 'ias': SAP Identity Authentication Service
|
|
27
|
-
* - Custom string types for user-defined authentication strategies
|
|
28
|
-
*
|
|
29
|
-
* Access CAP auth configuration via: cds.env.requires.auth.kind
|
|
30
|
-
*/
|
|
10
|
+
const logger_1 = require("../logger");
|
|
31
11
|
/* @ts-ignore */
|
|
32
|
-
const cds = global.cds || require("@sap/cds"); //
|
|
12
|
+
const cds = global.cds || require("@sap/cds"); // Use hosting app's CDS instance exclusively
|
|
33
13
|
/**
|
|
34
14
|
* Determines whether authentication is enabled for the MCP plugin.
|
|
35
15
|
*
|
|
@@ -117,7 +97,8 @@ function getAccessRights(authEnabled) {
|
|
|
117
97
|
* @since 1.0.0
|
|
118
98
|
*/
|
|
119
99
|
function registerAuthMiddleware(expressApp) {
|
|
120
|
-
|
|
100
|
+
logger_1.LOGGER.debug("Configuring auth middleware");
|
|
101
|
+
const middlewares = cds.middlewares?.before || []; // Handle missing middlewares gracefully
|
|
121
102
|
// Build array of auth middleware to apply
|
|
122
103
|
const authMiddleware = [];
|
|
123
104
|
// Add CAP middleware
|
|
@@ -132,8 +113,10 @@ function registerAuthMiddleware(expressApp) {
|
|
|
132
113
|
authMiddleware.push((0, handler_1.authHandlerFactory)());
|
|
133
114
|
// Apply auth middleware to all /mcp routes EXCEPT health
|
|
134
115
|
expressApp?.use(/^\/mcp(?!\/health).*/, ...authMiddleware);
|
|
135
|
-
//
|
|
116
|
+
// Note: .well-known/oauth-authorization-server endpoint is automatically created by mcpAuthRouter
|
|
117
|
+
// Configure OAuth proxy for enterprise authentication scenarios
|
|
136
118
|
configureOAuthProxy(expressApp);
|
|
119
|
+
logger_1.LOGGER.debug("Auth middleware configured");
|
|
137
120
|
}
|
|
138
121
|
/**
|
|
139
122
|
* Configures OAuth proxy middleware for enterprise authentication scenarios.
|
|
@@ -146,6 +129,7 @@ function registerAuthMiddleware(expressApp) {
|
|
|
146
129
|
* - Access token verification and validation
|
|
147
130
|
* - Client credential management
|
|
148
131
|
* - Integration with CAP authentication configuration
|
|
132
|
+
* - Dynamic client registration for MCP clients
|
|
149
133
|
*
|
|
150
134
|
* The OAuth proxy is only configured for enterprise authentication types
|
|
151
135
|
* (jwt, xsuaa, ias) and skips configuration for basic auth types.
|
|
@@ -175,42 +159,86 @@ function configureOAuthProxy(expressApp) {
|
|
|
175
159
|
const config = cds.env.requires.auth;
|
|
176
160
|
const kind = config.kind;
|
|
177
161
|
const credentials = config.credentials;
|
|
162
|
+
logger_1.LOGGER.debug("Running auth with configuration kind", kind);
|
|
178
163
|
// Safety guard - skip OAuth proxy for basic auth types
|
|
179
|
-
if (kind === "dummy" || kind === "mocked" || kind === "basic")
|
|
164
|
+
if (kind === "dummy" || kind === "mocked" || kind === "basic") {
|
|
165
|
+
logger_1.LOGGER.debug("Skipping OAuth proxy for auth type:", kind);
|
|
180
166
|
return;
|
|
181
|
-
|
|
167
|
+
}
|
|
168
|
+
if (!credentials ||
|
|
182
169
|
!credentials.clientid ||
|
|
183
170
|
!credentials.clientsecret ||
|
|
184
171
|
!credentials.url) {
|
|
185
|
-
|
|
172
|
+
logger_1.LOGGER.warn("OAuth proxy skipped - missing required XSUAA credentials");
|
|
173
|
+
return; // Don't throw error, just skip OAuth proxy
|
|
186
174
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
175
|
+
logger_1.LOGGER.debug("Configuring OAuth proxy with XSUAA endpoints");
|
|
176
|
+
const baseUrl = process.env.MCP_BASE_URL ||
|
|
177
|
+
process.env.MCP_SERVER_URL ||
|
|
178
|
+
"http://localhost:4004";
|
|
179
|
+
// const proxyProvider = new ProxyOAuthServerProvider({
|
|
180
|
+
// endpoints: {
|
|
181
|
+
// authorizationUrl: `${credentials.url}/oauth/authorize`,
|
|
182
|
+
// tokenUrl: `${credentials.url}/oauth/token`,
|
|
183
|
+
// revocationUrl: `${credentials.url}/oauth/revoke`,
|
|
184
|
+
// },
|
|
185
|
+
// verifyAccessToken: async (token: string) => {
|
|
186
|
+
// try {
|
|
187
|
+
// LOGGER.debug("OAuth proxy: verifyAccessToken called");
|
|
188
|
+
//
|
|
189
|
+
// // Use CAP's built-in JWT verification for XSUAA
|
|
190
|
+
// const decoded = await cds.auth.jwt.verify(token);
|
|
191
|
+
// LOGGER.debug(
|
|
192
|
+
// "Token decoded successfully for client:",
|
|
193
|
+
// decoded.client_id || decoded.azp,
|
|
194
|
+
// );
|
|
195
|
+
//
|
|
196
|
+
// return {
|
|
197
|
+
// token,
|
|
198
|
+
// clientId: decoded.client_id || decoded.azp,
|
|
199
|
+
// scopes: decoded.scope?.split(" ") || [],
|
|
200
|
+
// userId: decoded.sub,
|
|
201
|
+
// expiresAt: decoded.exp, // Unix timestamp, not Date object
|
|
202
|
+
// };
|
|
203
|
+
// } catch (error) {
|
|
204
|
+
// LOGGER.error("Token verification failed:", error);
|
|
205
|
+
// throw new Error("Invalid access token");
|
|
206
|
+
// }
|
|
207
|
+
// },
|
|
208
|
+
// getClient: async (client_id: string) => {
|
|
209
|
+
// LOGGER.debug("OAuth proxy: Dynamic client registration requested");
|
|
210
|
+
//
|
|
211
|
+
// return {
|
|
212
|
+
// client_secret: credentials.clientsecret as string,
|
|
213
|
+
// client_id,
|
|
214
|
+
// redirect_uris: [
|
|
215
|
+
// `${baseUrl}/oauth/callback`,
|
|
216
|
+
// `${baseUrl}/mcp/oauth/callback`,
|
|
217
|
+
// "http://localhost:3000/callback", // Claude Desktop default
|
|
218
|
+
// "http://localhost:3000/auth/callback", // Alternative format
|
|
219
|
+
// ],
|
|
220
|
+
// };
|
|
221
|
+
// },
|
|
222
|
+
// });
|
|
223
|
+
expressApp.use((0, router_js_1.mcpAuthMetadataRouter)({
|
|
224
|
+
oauthMetadata: {
|
|
225
|
+
issuer: credentials.url,
|
|
226
|
+
authorization_endpoint: `${credentials.url}/oauth/authorize`,
|
|
227
|
+
token_endpoint: `${credentials.url}/oauth/token`,
|
|
228
|
+
response_types_supported: ["code", "token"],
|
|
229
|
+
grant_types_supported: [
|
|
230
|
+
"authorization_code",
|
|
231
|
+
"client_credentials",
|
|
232
|
+
"urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
233
|
+
"refresh_token"
|
|
234
|
+
],
|
|
235
|
+
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
|
236
|
+
code_challenge_methods_supported: ["S256"]
|
|
199
237
|
},
|
|
200
|
-
getClient: async (client_id) => {
|
|
201
|
-
return {
|
|
202
|
-
client_secret: credentials.clientsecret,
|
|
203
|
-
client_id,
|
|
204
|
-
redirect_uris: ["http://localhost:3000/callback"], // Temporary value for now
|
|
205
|
-
};
|
|
206
|
-
},
|
|
207
|
-
});
|
|
208
|
-
expressApp.use((0, router_js_1.mcpAuthRouter)({
|
|
209
|
-
provider: proxyProvider,
|
|
210
|
-
issuerUrl: new URL(credentials.url),
|
|
211
|
-
//baseUrl: new URL(""), // I have left this out for the time being due to the defaulting to issuer
|
|
212
238
|
serviceDocumentationUrl: new URL("https://docs.cloudfoundry.org/api/uaa/version/77.34.0/index.html#authorization"),
|
|
239
|
+
resourceServerUrl: new URL(baseUrl),
|
|
213
240
|
}));
|
|
241
|
+
logger_1.LOGGER.info("OAuth proxy configured successfully for XSUAA integration");
|
|
214
242
|
}
|
|
215
243
|
/**
|
|
216
244
|
* Checks whether the requesting user's access matches that of the roles required
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Custom URI template implementation that fixes the MCP SDK's broken
|
|
4
|
+
* URI template matching for grouped query parameters.
|
|
5
|
+
*
|
|
6
|
+
* This is duck typing implementation of the ResourceTemplate class.
|
|
7
|
+
* See @modelcontextprotocol/sdk/server/mcp.js
|
|
8
|
+
*
|
|
9
|
+
* This is only a temporary solution, as we should use the official implementation from the SDK
|
|
10
|
+
* Upon the SDK being fixed, we should switch over to that implementation.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.CustomResourceTemplate = exports.CustomUriTemplate = void 0;
|
|
14
|
+
// TODO: Get rid of 'any' typing
|
|
15
|
+
/**
|
|
16
|
+
* Custom URI template class that properly handles grouped query parameters
|
|
17
|
+
* in the format {?param1,param2,param3}
|
|
18
|
+
*/
|
|
19
|
+
class CustomUriTemplate {
|
|
20
|
+
template;
|
|
21
|
+
baseUri = "";
|
|
22
|
+
queryParams = [];
|
|
23
|
+
constructor(template) {
|
|
24
|
+
this.template = template;
|
|
25
|
+
this.parseTemplate();
|
|
26
|
+
}
|
|
27
|
+
toString() {
|
|
28
|
+
return this.template;
|
|
29
|
+
}
|
|
30
|
+
parseTemplate() {
|
|
31
|
+
// Extract base URI and query parameters from template
|
|
32
|
+
// Template format: odata://CatalogService/books{?filter,orderby,select,skip,top}
|
|
33
|
+
const queryTemplateMatch = this.template.match(/^([^{]+)\{\?([^}]+)\}$/);
|
|
34
|
+
if (!queryTemplateMatch) {
|
|
35
|
+
// No query parameters, treat as static URI
|
|
36
|
+
this.baseUri = this.template;
|
|
37
|
+
this.queryParams = [];
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.baseUri = queryTemplateMatch[1];
|
|
41
|
+
this.queryParams = queryTemplateMatch[2]
|
|
42
|
+
.split(",")
|
|
43
|
+
.map((param) => param.trim())
|
|
44
|
+
.filter((param) => param.length > 0);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Matches a URI against this template and extracts variables
|
|
48
|
+
* @param uri The URI to match
|
|
49
|
+
* @returns Object with extracted variables or null if no match
|
|
50
|
+
*/
|
|
51
|
+
match(uri) {
|
|
52
|
+
// Check if base URI matches
|
|
53
|
+
if (!uri.startsWith(this.baseUri)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
// Extract query string
|
|
57
|
+
const queryStart = uri.indexOf("?");
|
|
58
|
+
if (queryStart === -1) {
|
|
59
|
+
// No query parameters in URI
|
|
60
|
+
if (this.queryParams.length === 0) {
|
|
61
|
+
return {}; // Static URI match
|
|
62
|
+
}
|
|
63
|
+
// Template expects query params but URI has none - still valid for optional params
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
const queryString = uri.substring(queryStart + 1);
|
|
67
|
+
const queryPairs = queryString.split("&");
|
|
68
|
+
const extractedVars = {};
|
|
69
|
+
// Parse query parameters with strict validation
|
|
70
|
+
for (const pair of queryPairs) {
|
|
71
|
+
const equalIndex = pair.indexOf("=");
|
|
72
|
+
if (equalIndex > 0) {
|
|
73
|
+
const key = pair.substring(0, equalIndex);
|
|
74
|
+
const value = pair.substring(equalIndex + 1);
|
|
75
|
+
if (key && value !== undefined) {
|
|
76
|
+
const decodedKey = decodeURIComponent(key);
|
|
77
|
+
const decodedValue = decodeURIComponent(value);
|
|
78
|
+
// SECURITY: Reject entire URI if ANY unauthorized parameter is present
|
|
79
|
+
if (!this.queryParams.includes(decodedKey)) {
|
|
80
|
+
return null; // Unauthorized parameter found - reject entire URI
|
|
81
|
+
}
|
|
82
|
+
extractedVars[decodedKey] = decodedValue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if (pair.trim().length > 0) {
|
|
86
|
+
// Handle malformed parameters (missing = or empty key)
|
|
87
|
+
// SECURITY: Reject malformed query parameters
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// For static templates (no parameters allowed), reject any query string
|
|
92
|
+
if (this.queryParams.length === 0 && queryString.trim().length > 0) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return extractedVars;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Expands the template with given variables
|
|
99
|
+
* @param variables Object containing variable values
|
|
100
|
+
* @returns Expanded URI string
|
|
101
|
+
*/
|
|
102
|
+
expand(variables) {
|
|
103
|
+
if (this.queryParams.length === 0) {
|
|
104
|
+
return this.baseUri;
|
|
105
|
+
}
|
|
106
|
+
const queryPairs = [];
|
|
107
|
+
for (const param of this.queryParams) {
|
|
108
|
+
const value = variables[param];
|
|
109
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
110
|
+
queryPairs.push(`${encodeURIComponent(param)}=${encodeURIComponent(value)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (queryPairs.length === 0) {
|
|
114
|
+
return this.baseUri;
|
|
115
|
+
}
|
|
116
|
+
return `${this.baseUri}?${queryPairs.join("&")}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Gets the variable names from the template
|
|
120
|
+
*/
|
|
121
|
+
get variableNames() {
|
|
122
|
+
return [...this.queryParams];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
exports.CustomUriTemplate = CustomUriTemplate;
|
|
126
|
+
/**
|
|
127
|
+
* Custom ResourceTemplate that uses our CustomUriTemplate for proper URI matching
|
|
128
|
+
* Duck-types the MCP SDK's ResourceTemplate interface for compatibility
|
|
129
|
+
*/
|
|
130
|
+
class CustomResourceTemplate {
|
|
131
|
+
_uriTemplate;
|
|
132
|
+
_callbacks;
|
|
133
|
+
constructor(uriTemplate, callbacks) {
|
|
134
|
+
this._callbacks = callbacks;
|
|
135
|
+
this._uriTemplate = new CustomUriTemplate(uriTemplate);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Gets the URI template pattern - must match MCP SDK interface
|
|
139
|
+
*/
|
|
140
|
+
get uriTemplate() {
|
|
141
|
+
return this._uriTemplate;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Gets the list callback, if one was provided
|
|
145
|
+
*/
|
|
146
|
+
get listCallback() {
|
|
147
|
+
return this._callbacks.list;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Gets the callback for completing a specific URI template variable
|
|
151
|
+
*/
|
|
152
|
+
completeCallback(variable) {
|
|
153
|
+
return this._callbacks.complete?.[variable];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
exports.CustomResourceTemplate = CustomResourceTemplate;
|
package/lib/mcp.js
CHANGED
|
@@ -40,7 +40,9 @@ class McpPlugin {
|
|
|
40
40
|
logger_1.LOGGER.debug("Event received for 'bootstrap'");
|
|
41
41
|
this.expressApp = app;
|
|
42
42
|
this.expressApp.use("/mcp", express_1.default.json());
|
|
43
|
-
if
|
|
43
|
+
// To make it more safe, if there is a mispelling we will always implement auth
|
|
44
|
+
// Users will have to explicitly write none
|
|
45
|
+
if (this.config.auth !== "none") {
|
|
44
46
|
(0, utils_2.registerAuthMiddleware)(this.expressApp);
|
|
45
47
|
}
|
|
46
48
|
await this.registerApiEndpoints();
|
package/lib/types.js
ADDED
package/lib/utils.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MCP_SESSION_HEADER = exports.LOGGER = void 0;
|
|
4
|
+
exports.createMcpServer = createMcpServer;
|
|
5
|
+
exports.handleMcpSessionRequest = handleMcpSessionRequest;
|
|
6
|
+
exports.parseEntityElements = parseEntityElements;
|
|
7
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
8
|
+
const zod_1 = require("zod");
|
|
9
|
+
const structures_1 = require("./annotations/structures");
|
|
10
|
+
/* @ts-ignore */
|
|
11
|
+
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
|
12
|
+
exports.LOGGER = cds.log("cds-mcp");
|
|
13
|
+
exports.MCP_SESSION_HEADER = "mcp-session-id";
|
|
14
|
+
function createMcpServer(annotations) {
|
|
15
|
+
const packageInfo = require("../package.json");
|
|
16
|
+
const server = new mcp_js_1.McpServer({
|
|
17
|
+
name: packageInfo.name,
|
|
18
|
+
version: packageInfo.version,
|
|
19
|
+
capabilities: {
|
|
20
|
+
tools: { listChanged: true },
|
|
21
|
+
resources: { listChanged: true },
|
|
22
|
+
prompts: { listChanged: true },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
exports.LOGGER.debug("Annotations found for server = ", annotations);
|
|
26
|
+
if (!annotations)
|
|
27
|
+
return server;
|
|
28
|
+
// TODO: Handle the parsed annotations
|
|
29
|
+
// TODO: Error handling
|
|
30
|
+
// TODO: This should only be mapped once, not per each server instance. Maybe this should be pre-packaged on load?
|
|
31
|
+
for (const [_, v] of annotations.entries()) {
|
|
32
|
+
switch (v.constructor) {
|
|
33
|
+
case structures_1.McpToolAnnotation:
|
|
34
|
+
const model = v;
|
|
35
|
+
const parameters = buildParameters(model.parameters);
|
|
36
|
+
exports.LOGGER.debug("Adding tool", model);
|
|
37
|
+
if (model.entityKey) {
|
|
38
|
+
const keys = buildParameters(model.keyTypeMap);
|
|
39
|
+
server.tool(model.name, { ...keys, ...parameters }, async (data) => {
|
|
40
|
+
const service = cds.services[model.serviceName];
|
|
41
|
+
const received = data;
|
|
42
|
+
const receivedKeys = {};
|
|
43
|
+
const receivedParams = {};
|
|
44
|
+
for (const [k, v] of Object.entries(received)) {
|
|
45
|
+
if (model.keyTypeMap?.has(k)) {
|
|
46
|
+
receivedKeys[k] = v;
|
|
47
|
+
}
|
|
48
|
+
if (!model.parameters?.has(k))
|
|
49
|
+
continue;
|
|
50
|
+
receivedParams[k] = v;
|
|
51
|
+
}
|
|
52
|
+
const response = await service.send({
|
|
53
|
+
event: model.target,
|
|
54
|
+
entity: model.entityKey,
|
|
55
|
+
data: receivedParams,
|
|
56
|
+
params: [receivedKeys],
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
content: Array.isArray(response)
|
|
60
|
+
? response.map((el) => ({ type: "text", text: String(el) }))
|
|
61
|
+
: [{ type: "text", text: String(response) }], // TODO: This should be dynamic based on the return type
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
server.tool(model.name, parameters, async (data) => {
|
|
67
|
+
exports.LOGGER.debug("Tool call received, targeting service: ", model.serviceName, model.target);
|
|
68
|
+
const service = cds.services[model.serviceName];
|
|
69
|
+
const response = await service.send(model.target, data);
|
|
70
|
+
exports.LOGGER.debug("MCP Tool response received and being packaged");
|
|
71
|
+
return {
|
|
72
|
+
content: Array.isArray(response)
|
|
73
|
+
? response.map((el) => ({ type: "text", text: String(el) }))
|
|
74
|
+
: [{ type: "text", text: String(response) }], // TODO: This should be dynamic based on the return type
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
case structures_1.McpResourceAnnotation:
|
|
79
|
+
exports.LOGGER.debug("This is a resource");
|
|
80
|
+
continue;
|
|
81
|
+
case structures_1.McpPromptAnnotation:
|
|
82
|
+
exports.LOGGER.debug("This is a prompt");
|
|
83
|
+
continue;
|
|
84
|
+
default:
|
|
85
|
+
exports.LOGGER.error("Invalid annotation data type");
|
|
86
|
+
throw new Error("Invalid annotation");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return server;
|
|
90
|
+
}
|
|
91
|
+
async function handleMcpSessionRequest(req, res, sessions) {
|
|
92
|
+
const sessionIdHeader = req.headers[exports.MCP_SESSION_HEADER];
|
|
93
|
+
if (!sessionIdHeader || !sessions.has(sessionIdHeader)) {
|
|
94
|
+
res.status(400).send("Invalid or missing session ID");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const session = sessions.get(sessionIdHeader);
|
|
98
|
+
if (!session) {
|
|
99
|
+
res.status(400).send("Invalid session");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await session.transport.handleRequest(req, res);
|
|
103
|
+
}
|
|
104
|
+
function parseEntityElements(entity) {
|
|
105
|
+
const elements = entity.elements;
|
|
106
|
+
if (!elements) {
|
|
107
|
+
exports.LOGGER.error("Invalid object - cannot be parsed", entity);
|
|
108
|
+
throw new Error("Failed to parse entity object");
|
|
109
|
+
}
|
|
110
|
+
const result = new Map();
|
|
111
|
+
for (const el of elements) {
|
|
112
|
+
if (!el.type)
|
|
113
|
+
continue;
|
|
114
|
+
result.set(el.name, el.type?.replace("cds.", ""));
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
function buildParameters(params) {
|
|
119
|
+
if (!params || params.size <= 0)
|
|
120
|
+
return {};
|
|
121
|
+
const result = {};
|
|
122
|
+
for (const [k, v] of params.entries()) {
|
|
123
|
+
result[k] = determineParameterType(v);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
function determineParameterType(paramType) {
|
|
128
|
+
switch (paramType) {
|
|
129
|
+
case "String":
|
|
130
|
+
return zod_1.z.string();
|
|
131
|
+
case "Integer":
|
|
132
|
+
return zod_1.z.number();
|
|
133
|
+
default:
|
|
134
|
+
return zod_1.z.number();
|
|
135
|
+
}
|
|
136
|
+
}
|
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-alpha.3",
|
|
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.4",
|
|
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",
|