@gavdi/cap-mcp 0.9.3 โ 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -1
- package/cds-plugin.js +1 -1
- package/lib/annotations/constants.js +2 -0
- package/lib/annotations/parser.js +11 -5
- package/lib/annotations/structures.js +10 -1
- package/lib/auth/utils.js +81 -0
- package/lib/config/loader.js +7 -0
- package/lib/logger.js +48 -2
- package/lib/mcp/describe-model.js +103 -0
- package/lib/mcp/entity-tools.js +548 -0
- package/lib/mcp/factory.js +14 -0
- package/lib/mcp/resources.js +18 -3
- package/lib/mcp/session-manager.js +13 -2
- package/lib/mcp/tools.js +10 -18
- package/lib/mcp/utils.js +56 -0
- package/lib/mcp.js +23 -5
- package/package.json +12 -7
- package/lib/annotations.js +0 -257
- package/lib/auth/adapter.js +0 -2
- package/lib/auth/mock.js +0 -2
- package/lib/auth/types.js +0 -2
- package/lib/mcp/customResourceTemplate.js +0 -156
- package/lib/types.js +0 -2
- package/lib/utils.js +0 -136
package/README.md
CHANGED
@@ -60,7 +60,9 @@ Add MCP configuration to your `package.json`:
|
|
60
60
|
"cds": {
|
61
61
|
"mcp": {
|
62
62
|
"name": "my-bookshop-mcp",
|
63
|
-
"auth": "inherit"
|
63
|
+
"auth": "inherit",
|
64
|
+
"wrap_entities_to_actions": false,
|
65
|
+
"wrap_entity_modes": ["query", "get"]
|
64
66
|
}
|
65
67
|
}
|
66
68
|
}
|
@@ -80,6 +82,13 @@ service CatalogService {
|
|
80
82
|
resource: ['filter', 'orderby', 'select', 'top', 'skip']
|
81
83
|
}
|
82
84
|
entity Books as projection on my.Books;
|
85
|
+
|
86
|
+
// Optionally expose Books as tools for LLMs (query/get enabled by default config)
|
87
|
+
annotate CatalogService.Books with @mcp.wrap: {
|
88
|
+
tools: true,
|
89
|
+
modes: ['query','get'],
|
90
|
+
hint: 'Use for read-only lookups of books'
|
91
|
+
};
|
83
92
|
|
84
93
|
@mcp: {
|
85
94
|
name: 'get-book-recommendations',
|
@@ -114,10 +123,32 @@ This plugin transforms your annotated CAP services into a fully functional MCP s
|
|
114
123
|
|
115
124
|
- **๐ Resources**: Expose CAP entities as MCP resources with OData v4 query capabilities
|
116
125
|
- **๐ง Tools**: Convert CAP functions and actions into executable MCP tools
|
126
|
+
- **๐งฉ Entity Wrappers (optional)**: Expose CAP entities as tools (`query`, `get`, and optionally `create`, `update`) for LLM tool use while keeping resources intact
|
117
127
|
- **๐ก Prompts**: Define reusable prompt templates for AI interactions
|
118
128
|
- **๐ Auto-generation**: Automatically creates MCP server endpoints based on annotations
|
119
129
|
- **โ๏ธ Flexible Configuration**: Support for custom parameter sets and descriptions
|
120
130
|
|
131
|
+
## ๐งช Testing & Inspector
|
132
|
+
|
133
|
+
- Run tests: `npm test`
|
134
|
+
- Start demo app: `npm run mock`
|
135
|
+
- Inspector: `npx @modelcontextprotocol/inspector`
|
136
|
+
|
137
|
+
### New wrapper tools
|
138
|
+
|
139
|
+
When `wrap_entities_to_actions` is enabled (globally or via `@mcp.wrap.tools: true`), you will see tools named like:
|
140
|
+
|
141
|
+
- `CatalogService_Books_query`
|
142
|
+
- `CatalogService_Books_get`
|
143
|
+
- `CatalogService_Books_create` (if enabled)
|
144
|
+
- `CatalogService_Books_update` (if enabled)
|
145
|
+
|
146
|
+
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.
|
147
|
+
|
148
|
+
### Bruno collection
|
149
|
+
|
150
|
+
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.
|
151
|
+
|
121
152
|
## ๐ Usage
|
122
153
|
|
123
154
|
### Resource Annotations
|
@@ -425,6 +456,10 @@ npm test -- --verbose
|
|
425
456
|
npm test -- --watch
|
426
457
|
```
|
427
458
|
|
459
|
+
### Further reading
|
460
|
+
|
461
|
+
- Short guide on entity tools and configuration: `docs/entity-tools.md`
|
462
|
+
|
428
463
|
## ๐ค Contributing
|
429
464
|
|
430
465
|
Contributions are welcome! This is an open-source project aimed at bridging CAP applications with the AI ecosystem.
|
package/cds-plugin.js
CHANGED
@@ -25,6 +25,8 @@ exports.MCP_ANNOTATION_PROPS = {
|
|
25
25
|
MCP_TOOL: "@mcp.tool",
|
26
26
|
/** Prompt templates annotation for CAP services */
|
27
27
|
MCP_PROMPT: "@mcp.prompts",
|
28
|
+
/** Wrapper configuration for exposing entities as tools */
|
29
|
+
MCP_WRAP: "@mcp.wrap",
|
28
30
|
};
|
29
31
|
/**
|
30
32
|
* Default set of all available OData query options for MCP resources
|
@@ -18,16 +18,18 @@ function parseDefinitions(model) {
|
|
18
18
|
}
|
19
19
|
const result = new Map();
|
20
20
|
for (const [key, value] of Object.entries(model.definitions)) {
|
21
|
-
|
21
|
+
// Narrow unknown to csn.Definition with a runtime check
|
22
|
+
const def = value;
|
23
|
+
const parsedAnnotations = parseAnnotations(def);
|
22
24
|
const { serviceName, target } = (0, utils_1.splitDefinitionName)(key);
|
23
|
-
parseBoundOperations(serviceName, target,
|
25
|
+
parseBoundOperations(serviceName, target, def, result); // Mutates result map with bound operations
|
24
26
|
if (!parsedAnnotations || !(0, utils_1.containsRequiredAnnotations)(parsedAnnotations)) {
|
25
27
|
continue; // This check must occur here, since we do want the bound operations even if the parent is not annotated
|
26
28
|
}
|
27
29
|
const verifiedAnnotations = parsedAnnotations;
|
28
|
-
switch (
|
30
|
+
switch (def.kind) {
|
29
31
|
case "entity":
|
30
|
-
const resourceAnnotation = constructResourceAnnotation(serviceName, target, verifiedAnnotations,
|
32
|
+
const resourceAnnotation = constructResourceAnnotation(serviceName, target, verifiedAnnotations, def);
|
31
33
|
if (!resourceAnnotation)
|
32
34
|
continue;
|
33
35
|
result.set(resourceAnnotation.target, resourceAnnotation);
|
@@ -87,6 +89,10 @@ function parseAnnotations(definition) {
|
|
87
89
|
case constants_1.MCP_ANNOTATION_PROPS.MCP_PROMPT:
|
88
90
|
annotations.prompts = v;
|
89
91
|
continue;
|
92
|
+
case constants_1.MCP_ANNOTATION_PROPS.MCP_WRAP:
|
93
|
+
// Wrapper container to expose resources as tools
|
94
|
+
annotations.wrap = v;
|
95
|
+
continue;
|
90
96
|
default:
|
91
97
|
continue;
|
92
98
|
}
|
@@ -106,7 +112,7 @@ function constructResourceAnnotation(serviceName, target, annotations, definitio
|
|
106
112
|
return undefined;
|
107
113
|
const functionalities = (0, utils_1.determineResourceOptions)(annotations);
|
108
114
|
const { properties, resourceKeys } = (0, utils_1.parseResourceElements)(definition);
|
109
|
-
return new structures_1.McpResourceAnnotation(annotations.name, annotations.description, target, serviceName, functionalities, properties, resourceKeys);
|
115
|
+
return new structures_1.McpResourceAnnotation(annotations.name, annotations.description, target, serviceName, functionalities, properties, resourceKeys, annotations.wrap);
|
110
116
|
}
|
111
117
|
/**
|
112
118
|
* Constructs a tool annotation from parsed annotation data
|
@@ -68,6 +68,8 @@ class McpResourceAnnotation extends McpAnnotation {
|
|
68
68
|
_properties;
|
69
69
|
/** Map of resource key fields to their types */
|
70
70
|
_resourceKeys;
|
71
|
+
/** Optional wrapper configuration to expose this resource as tools */
|
72
|
+
_wrap;
|
71
73
|
/**
|
72
74
|
* Creates a new MCP resource annotation
|
73
75
|
* @param name - Unique identifier for this resource
|
@@ -78,11 +80,12 @@ class McpResourceAnnotation extends McpAnnotation {
|
|
78
80
|
* @param properties - Map of entity properties to their CDS types
|
79
81
|
* @param resourceKeys - Map of key fields to their types
|
80
82
|
*/
|
81
|
-
constructor(name, description, target, serviceName, functionalities, properties, resourceKeys) {
|
83
|
+
constructor(name, description, target, serviceName, functionalities, properties, resourceKeys, wrap) {
|
82
84
|
super(name, description, target, serviceName);
|
83
85
|
this._functionalities = functionalities;
|
84
86
|
this._properties = properties;
|
85
87
|
this._resourceKeys = resourceKeys;
|
88
|
+
this._wrap = wrap;
|
86
89
|
}
|
87
90
|
/**
|
88
91
|
* Gets the set of enabled OData query functionalities
|
@@ -105,6 +108,12 @@ class McpResourceAnnotation extends McpAnnotation {
|
|
105
108
|
get resourceKeys() {
|
106
109
|
return this._resourceKeys;
|
107
110
|
}
|
111
|
+
/**
|
112
|
+
* Gets the wrapper configuration for exposing this resource as tools
|
113
|
+
*/
|
114
|
+
get wrap() {
|
115
|
+
return this._wrap;
|
116
|
+
}
|
108
117
|
}
|
109
118
|
exports.McpResourceAnnotation = McpResourceAnnotation;
|
110
119
|
/**
|
package/lib/auth/utils.js
CHANGED
@@ -4,6 +4,8 @@ exports.isAuthEnabled = isAuthEnabled;
|
|
4
4
|
exports.getAccessRights = getAccessRights;
|
5
5
|
exports.registerAuthMiddleware = registerAuthMiddleware;
|
6
6
|
const handler_1 = require("./handler");
|
7
|
+
const proxyProvider_js_1 = require("@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js");
|
8
|
+
const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js");
|
7
9
|
/**
|
8
10
|
* @fileoverview Authentication utilities for MCP-CAP integration.
|
9
11
|
*
|
@@ -128,4 +130,83 @@ function registerAuthMiddleware(expressApp) {
|
|
128
130
|
authMiddleware.push((0, handler_1.authHandlerFactory)());
|
129
131
|
// Apply auth middleware to all /mcp routes EXCEPT health
|
130
132
|
expressApp?.use(/^\/mcp(?!\/health).*/, ...authMiddleware);
|
133
|
+
// Then finally we add the oauth proxy to the xsuaa instance
|
134
|
+
configureOAuthProxy(expressApp);
|
135
|
+
}
|
136
|
+
/**
|
137
|
+
* Configures OAuth proxy middleware for enterprise authentication scenarios.
|
138
|
+
*
|
139
|
+
* This function sets up a proxy OAuth provider that integrates with SAP BTP
|
140
|
+
* authentication services (XSUAA/IAS) to enable MCP clients to authenticate
|
141
|
+
* through standard OAuth2 flows. The proxy handles:
|
142
|
+
*
|
143
|
+
* - OAuth2 authorization and token endpoints
|
144
|
+
* - Access token verification and validation
|
145
|
+
* - Client credential management
|
146
|
+
* - Integration with CAP authentication configuration
|
147
|
+
*
|
148
|
+
* The OAuth proxy is only configured for enterprise authentication types
|
149
|
+
* (jwt, xsuaa, ias) and skips configuration for basic auth types.
|
150
|
+
*
|
151
|
+
* @param expressApp - Express application instance to register OAuth routes on
|
152
|
+
*
|
153
|
+
* @throws {Error} When required OAuth credentials are missing or invalid
|
154
|
+
*
|
155
|
+
* @example
|
156
|
+
* ```typescript
|
157
|
+
* // Automatically called by registerAuthMiddleware()
|
158
|
+
* // Requires CAP auth configuration:
|
159
|
+
* // cds.env.requires.auth = {
|
160
|
+
* // kind: 'xsuaa',
|
161
|
+
* // credentials: {
|
162
|
+
* // clientid: 'your-client-id',
|
163
|
+
* // clientsecret: 'your-client-secret',
|
164
|
+
* // url: 'https://your-tenant.authentication.sap.hana.ondemand.com'
|
165
|
+
* // }
|
166
|
+
* // }
|
167
|
+
* ```
|
168
|
+
*
|
169
|
+
* @internal This function is called internally by registerAuthMiddleware()
|
170
|
+
* @since 1.0.0
|
171
|
+
*/
|
172
|
+
function configureOAuthProxy(expressApp) {
|
173
|
+
const config = cds.env.requires.auth;
|
174
|
+
const kind = config.kind;
|
175
|
+
const credentials = config.credentials;
|
176
|
+
// Safety guard - skip OAuth proxy for basic auth types
|
177
|
+
if (kind === "dummy" || kind === "mocked" || kind === "basic")
|
178
|
+
return;
|
179
|
+
else if (!credentials ||
|
180
|
+
!credentials.clientid ||
|
181
|
+
!credentials.clientsecret ||
|
182
|
+
!credentials.url) {
|
183
|
+
throw new Error("Invalid security credentials");
|
184
|
+
}
|
185
|
+
const proxyProvider = new proxyProvider_js_1.ProxyOAuthServerProvider({
|
186
|
+
endpoints: {
|
187
|
+
authorizationUrl: `${credentials.url}/oauth/authorize`,
|
188
|
+
tokenUrl: `${credentials.url}/oauth/token`,
|
189
|
+
revocationUrl: `${credentials.url}/oauth/revoke`,
|
190
|
+
},
|
191
|
+
verifyAccessToken: async (token) => {
|
192
|
+
return {
|
193
|
+
token,
|
194
|
+
clientId: credentials.clientid,
|
195
|
+
scopes: ["uaa.resource"],
|
196
|
+
};
|
197
|
+
},
|
198
|
+
getClient: async (client_id) => {
|
199
|
+
return {
|
200
|
+
client_secret: credentials.clientsecret,
|
201
|
+
client_id,
|
202
|
+
redirect_uris: ["http://localhost:3000/callback"], // Temporary value for now
|
203
|
+
};
|
204
|
+
},
|
205
|
+
});
|
206
|
+
expressApp.use((0, router_js_1.mcpAuthRouter)({
|
207
|
+
provider: proxyProvider,
|
208
|
+
issuerUrl: new URL(credentials.url),
|
209
|
+
//baseUrl: new URL(""), // I have left this out for the time being due to the defaulting to issuer
|
210
|
+
serviceDocumentationUrl: new URL("https://docs.cloudfoundry.org/api/uaa/version/77.34.0/index.html#authorization"),
|
211
|
+
}));
|
131
212
|
}
|
package/lib/config/loader.js
CHANGED
@@ -28,6 +28,13 @@ function loadConfiguration() {
|
|
28
28
|
resources: cdsEnv?.capabilities?.resources ?? { listChanged: true },
|
29
29
|
prompts: cdsEnv?.capabilities?.prompts ?? { listChanged: true },
|
30
30
|
},
|
31
|
+
wrap_entities_to_actions: cdsEnv?.wrap_entities_to_actions ?? false,
|
32
|
+
wrap_entity_modes: cdsEnv?.wrap_entity_modes ?? [
|
33
|
+
"query",
|
34
|
+
"get",
|
35
|
+
"create",
|
36
|
+
"update",
|
37
|
+
],
|
31
38
|
};
|
32
39
|
}
|
33
40
|
/**
|
package/lib/logger.js
CHANGED
@@ -7,8 +7,54 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
exports.LOGGER = void 0;
|
8
8
|
/* @ts-ignore */
|
9
9
|
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
10
|
+
// In some test/mocked environments cds.log may not exist. Provide a no-op fallback.
|
11
|
+
const safeLog = (ns) => {
|
12
|
+
try {
|
13
|
+
if (typeof cds?.log === "function")
|
14
|
+
return cds.log(ns);
|
15
|
+
}
|
16
|
+
catch { }
|
17
|
+
return {
|
18
|
+
debug: () => { },
|
19
|
+
info: () => { },
|
20
|
+
warn: () => { },
|
21
|
+
error: () => { },
|
22
|
+
};
|
23
|
+
};
|
24
|
+
// Create both channels so logs show up even if the app configured "mcp" instead of "cds-mcp"
|
25
|
+
const loggerPrimary = safeLog("cds-mcp");
|
26
|
+
const loggerCompat = safeLog("mcp");
|
10
27
|
/**
|
11
28
|
* Shared logger instance for all MCP plugin components
|
12
|
-
*
|
29
|
+
* Multiplexes logs to both "cds-mcp" and legacy "mcp" channels for visibility
|
13
30
|
*/
|
14
|
-
exports.LOGGER =
|
31
|
+
exports.LOGGER = {
|
32
|
+
debug: (...args) => {
|
33
|
+
try {
|
34
|
+
loggerPrimary?.debug?.(...args);
|
35
|
+
loggerCompat?.debug?.(...args);
|
36
|
+
}
|
37
|
+
catch { }
|
38
|
+
},
|
39
|
+
info: (...args) => {
|
40
|
+
try {
|
41
|
+
loggerPrimary?.info?.(...args);
|
42
|
+
loggerCompat?.info?.(...args);
|
43
|
+
}
|
44
|
+
catch { }
|
45
|
+
},
|
46
|
+
warn: (...args) => {
|
47
|
+
try {
|
48
|
+
loggerPrimary?.warn?.(...args);
|
49
|
+
loggerCompat?.warn?.(...args);
|
50
|
+
}
|
51
|
+
catch { }
|
52
|
+
},
|
53
|
+
error: (...args) => {
|
54
|
+
try {
|
55
|
+
loggerPrimary?.error?.(...args);
|
56
|
+
loggerCompat?.error?.(...args);
|
57
|
+
}
|
58
|
+
catch { }
|
59
|
+
},
|
60
|
+
};
|
@@ -0,0 +1,103 @@
|
|
1
|
+
"use strict";
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
3
|
+
exports.registerDescribeModelTool = registerDescribeModelTool;
|
4
|
+
const utils_1 = require("./utils");
|
5
|
+
const zod_1 = require("zod");
|
6
|
+
/**
|
7
|
+
* Registers a discovery tool that describes CAP services/entities and fields.
|
8
|
+
* Helpful for models to plan correct tool calls without trial-and-error.
|
9
|
+
*/
|
10
|
+
function registerDescribeModelTool(server) {
|
11
|
+
const inputZod = zod_1.z
|
12
|
+
.object({
|
13
|
+
service: zod_1.z.string().optional(),
|
14
|
+
entity: zod_1.z.string().optional(),
|
15
|
+
format: zod_1.z.enum(["concise", "detailed"]).default("concise").optional(),
|
16
|
+
})
|
17
|
+
.strict();
|
18
|
+
const inputSchema = {
|
19
|
+
service: inputZod.shape.service,
|
20
|
+
entity: inputZod.shape.entity,
|
21
|
+
format: inputZod.shape.format,
|
22
|
+
};
|
23
|
+
server.registerTool("cap_describe_model", {
|
24
|
+
title: "cap_describe_model",
|
25
|
+
description: "Describe CAP services/entities and their fields, keys, and example tool calls. Use this to guide LLMs how to call entity wrapper tools.",
|
26
|
+
inputSchema,
|
27
|
+
}, async (rawArgs) => {
|
28
|
+
const args = inputZod.parse(rawArgs);
|
29
|
+
const CDS = global.cds;
|
30
|
+
const refl = CDS.reflect(CDS.model);
|
31
|
+
const listServices = () => {
|
32
|
+
const names = Object.values(CDS.services || {})
|
33
|
+
.map((s) => s?.definition?.name || s?.name)
|
34
|
+
.filter(Boolean);
|
35
|
+
return { services: [...new Set(names)].sort() };
|
36
|
+
};
|
37
|
+
const listEntities = (service) => {
|
38
|
+
const all = Object.values(refl.entities || {}) || [];
|
39
|
+
const filtered = service
|
40
|
+
? all.filter((e) => String(e.name).startsWith(service + "."))
|
41
|
+
: all;
|
42
|
+
return {
|
43
|
+
entities: filtered.map((e) => e.name).sort(),
|
44
|
+
};
|
45
|
+
};
|
46
|
+
const describeEntity = (service, entity) => {
|
47
|
+
if (!entity)
|
48
|
+
return { error: "Please provide 'entity'." };
|
49
|
+
const fqn = service && !entity.includes(".") ? `${service}.${entity}` : entity;
|
50
|
+
const ent = (refl.entities || {})[fqn] || (refl.entities || {})[entity];
|
51
|
+
if (!ent)
|
52
|
+
return {
|
53
|
+
error: `Entity not found: ${entity}${service ? ` (service ${service})` : ""}`,
|
54
|
+
};
|
55
|
+
const elements = Object.entries(ent.elements || {}).map(([name, el]) => ({
|
56
|
+
name,
|
57
|
+
type: el.type,
|
58
|
+
key: !!el.key,
|
59
|
+
target: el.target || undefined,
|
60
|
+
isArray: !!el.items,
|
61
|
+
}));
|
62
|
+
const keys = elements.filter((e) => e.key).map((e) => e.name);
|
63
|
+
const sampleTop = 5;
|
64
|
+
const shortFields = elements.slice(0, 5).map((e) => e.name);
|
65
|
+
// Match wrapper tool naming: Service_Entity_mode
|
66
|
+
const entName = String(ent?.name || "entity");
|
67
|
+
const svcPart = service || entName.split(".")[0] || "Service";
|
68
|
+
const entityBase = entName.split(".").pop() || "Entity";
|
69
|
+
const listName = `${svcPart}_${entityBase}_query`;
|
70
|
+
const getName = `${svcPart}_${entityBase}_get`;
|
71
|
+
return {
|
72
|
+
service,
|
73
|
+
entity: ent.name,
|
74
|
+
keys,
|
75
|
+
fields: elements,
|
76
|
+
usage: {
|
77
|
+
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.",
|
79
|
+
},
|
80
|
+
examples: {
|
81
|
+
list_tool: listName,
|
82
|
+
list_tool_payload: {
|
83
|
+
top: sampleTop,
|
84
|
+
select: shortFields,
|
85
|
+
},
|
86
|
+
get_tool: getName,
|
87
|
+
get_tool_payload: keys.length ? { [keys[0]]: "<value>" } : {},
|
88
|
+
},
|
89
|
+
};
|
90
|
+
};
|
91
|
+
let json;
|
92
|
+
if (!args.service && !args.entity) {
|
93
|
+
json = { ...listServices(), ...listEntities(undefined) };
|
94
|
+
}
|
95
|
+
else if (args.service && !args.entity) {
|
96
|
+
json = { service: args.service, ...listEntities(args.service) };
|
97
|
+
}
|
98
|
+
else {
|
99
|
+
json = describeEntity(args.service, args.entity);
|
100
|
+
}
|
101
|
+
return (0, utils_1.asMcpResult)(json);
|
102
|
+
});
|
103
|
+
}
|