@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 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
@@ -1,4 +1,4 @@
1
- const cds = global.cds || require("@sap/cds");
1
+ const cds = global.cds; // enforce host app cds instance
2
2
  const McpPlugin = require("./lib/mcp").default;
3
3
 
4
4
  const plugin = new McpPlugin();
@@ -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
- const parsedAnnotations = parseAnnotations(value);
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, value, result); // Mutates result map with bound operations
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 (value.kind) {
30
+ switch (def.kind) {
29
31
  case "entity":
30
- const resourceAnnotation = constructResourceAnnotation(serviceName, target, verifiedAnnotations, value);
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
  }
@@ -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
- * Provides debug, info, warn, and error logging methods
29
+ * Multiplexes logs to both "cds-mcp" and legacy "mcp" channels for visibility
13
30
  */
14
- exports.LOGGER = cds.log("cds-mcp");
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
+ }