@gavdi/cap-mcp 0.9.0

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.
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assignResourceToServer = assignResourceToServer;
4
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
5
+ const logger_1 = require("../logger");
6
+ const utils_1 = require("./utils");
7
+ // import cds from "@sap/cds";
8
+ /* @ts-ignore */
9
+ const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
10
+ function assignResourceToServer(model, server) {
11
+ logger_1.LOGGER.debug("Adding resource", model);
12
+ if (model.functionalities.size <= 0) {
13
+ registerStaticResource(model, server);
14
+ }
15
+ // Dynamic resource registration
16
+ const detailedDescription = (0, utils_1.writeODataDescriptionForResource)(model);
17
+ const functionalities = Array.from(model.functionalities).map((el) => `{?${el}}`);
18
+ // BUG: RFC compliance breaking bug in the MCP SDK library, must wait for fix....
19
+ const resourceTemplateUri = `odata://${model.serviceName}/${model.name}${functionalities.join("")}`;
20
+ const template = new mcp_js_1.ResourceTemplate(resourceTemplateUri, {
21
+ list: undefined,
22
+ });
23
+ server.registerResource(model.name, template, { title: model.target, description: detailedDescription }, async (uri, queryParameters) => {
24
+ const service = cds.services[model.serviceName];
25
+ const query = SELECT.from(model.target).limit(queryParameters.top ? Number(queryParameters.top) : 100, queryParameters.skip ? Number(queryParameters.skip) : undefined);
26
+ for (const [k, v] of Object.entries(queryParameters)) {
27
+ switch (k) {
28
+ case "filter":
29
+ const expression = cds.parse.expr(decodeURIComponent(v));
30
+ query.where(expression);
31
+ continue;
32
+ case "select":
33
+ const decodedSelect = decodeURIComponent(v);
34
+ query.columns(decodedSelect.split(","));
35
+ continue;
36
+ case "orderby":
37
+ query.orderBy(decodeURIComponent(v));
38
+ continue;
39
+ default:
40
+ continue;
41
+ }
42
+ }
43
+ try {
44
+ const response = await service.run(query);
45
+ return {
46
+ contents: [
47
+ {
48
+ uri: uri.href,
49
+ text: response ? JSON.stringify(response) : "",
50
+ },
51
+ ],
52
+ };
53
+ }
54
+ catch (e) {
55
+ logger_1.LOGGER.error(`Failed to retrieve resource data for ${model.target}`, e);
56
+ return {
57
+ contents: [
58
+ {
59
+ uri: uri.href,
60
+ text: "ERROR: Failed to find data due to unexpected error",
61
+ },
62
+ ],
63
+ };
64
+ }
65
+ });
66
+ }
67
+ function registerStaticResource(model, server) {
68
+ server.registerResource(model.name, `odata://${model.serviceName}/${model.name}`, { title: model.target, description: model.description }, async (uri, queryParameters) => {
69
+ const service = cds.services[model.serviceName];
70
+ const query = SELECT.from(model.target).limit(queryParameters.top ? Number(queryParameters.top) : 100);
71
+ try {
72
+ const response = await service.run(query);
73
+ return {
74
+ contents: [
75
+ {
76
+ uri: uri.href,
77
+ text: response ? JSON.stringify(response) : "",
78
+ },
79
+ ],
80
+ };
81
+ }
82
+ catch (e) {
83
+ logger_1.LOGGER.error(`Failed to retrieve resource data for ${model.target}`, e);
84
+ return {
85
+ contents: [
86
+ {
87
+ uri: uri.href,
88
+ text: "ERROR: Failed to find data due to unexpected error",
89
+ },
90
+ ],
91
+ };
92
+ }
93
+ });
94
+ }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assignToolToServer = assignToolToServer;
4
+ const utils_1 = require("./utils");
5
+ const logger_1 = require("../logger");
6
+ const constants_1 = require("./constants");
7
+ /* @ts-ignore */
8
+ const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
9
+ /**
10
+ * Assigns the annotated tool to the server.
11
+ * This is done by reference, and will therefore mutate the provided server.
12
+ */
13
+ function assignToolToServer(model, server) {
14
+ logger_1.LOGGER.debug("Adding tool", model);
15
+ const parameters = buildToolParameters(model.parameters);
16
+ if (model.entityKey) {
17
+ // Assign tool as bound operation
18
+ assignBoundOperation(parameters, model, server);
19
+ return;
20
+ }
21
+ assignUnboundOperation(parameters, model, server);
22
+ }
23
+ /**
24
+ * Creates tool handler for bound action/function imports
25
+ */
26
+ function assignBoundOperation(params, model, server) {
27
+ if (!model.keyTypeMap || model.keyTypeMap.size <= 0) {
28
+ logger_1.LOGGER.error("Invalid tool assignment - missing key map for bound operation");
29
+ throw new Error("Bound operation cannot be assigned to tool list, missing keys");
30
+ }
31
+ const keys = buildToolParameters(model.keyTypeMap);
32
+ server.registerTool(model.name, { ...keys, ...params }, async (data) => {
33
+ const service = cds.services[model.serviceName];
34
+ if (!service) {
35
+ logger_1.LOGGER.error("Invalid CAP service - undefined");
36
+ return {
37
+ isError: true,
38
+ content: [
39
+ {
40
+ type: "text",
41
+ text: constants_1.ERR_MISSING_SERVICE,
42
+ },
43
+ ],
44
+ };
45
+ }
46
+ const operationInput = {};
47
+ const operationKeys = {};
48
+ for (const [k, v] of Object.entries(data)) {
49
+ if (model.keyTypeMap?.has(k)) {
50
+ operationKeys[k] = v;
51
+ }
52
+ if (!model.parameters?.has(k))
53
+ continue;
54
+ operationInput[k] = v;
55
+ }
56
+ const response = await service.send({
57
+ event: model.target,
58
+ entity: model.entityKey,
59
+ data: operationInput,
60
+ params: [operationKeys],
61
+ });
62
+ return {
63
+ content: Array.isArray(response)
64
+ ? response.map((el) => ({ type: "text", text: String(el) }))
65
+ : [{ type: "text", text: String(response) }],
66
+ };
67
+ });
68
+ }
69
+ /**
70
+ * Creates a tool handler for unbound action/function imports
71
+ */
72
+ function assignUnboundOperation(params, model, server) {
73
+ server.registerTool(model.name, params, async (data) => {
74
+ const service = cds.services[model.serviceName];
75
+ if (!service) {
76
+ logger_1.LOGGER.error("Invalid CAP service - undefined");
77
+ return {
78
+ isError: true,
79
+ content: [
80
+ {
81
+ type: "text",
82
+ text: constants_1.ERR_MISSING_SERVICE,
83
+ },
84
+ ],
85
+ };
86
+ }
87
+ const response = await service.send(model.target, data);
88
+ return {
89
+ content: Array.isArray(response)
90
+ ? response.map((el) => ({ type: "text", text: String(el) }))
91
+ : [{ type: "text", text: String(response) }],
92
+ };
93
+ });
94
+ }
95
+ /**
96
+ * Builds the parameters that the MCP server should take in for the given tool's parameters
97
+ */
98
+ function buildToolParameters(params) {
99
+ if (!params || params.size <= 0)
100
+ return {};
101
+ const result = {};
102
+ for (const [k, v] of params.entries()) {
103
+ result[k] = (0, utils_1.determineMcpParameterType)(v);
104
+ }
105
+ return result;
106
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.determineMcpParameterType = determineMcpParameterType;
4
+ exports.handleMcpSessionRequest = handleMcpSessionRequest;
5
+ exports.writeODataDescriptionForResource = writeODataDescriptionForResource;
6
+ const constants_1 = require("./constants");
7
+ const zod_1 = require("zod");
8
+ /**
9
+ * Takes in the string based type name of the CDS type found through CSN and converts it to zod type
10
+ */
11
+ function determineMcpParameterType(cdsType) {
12
+ switch (cdsType) {
13
+ case "String":
14
+ return zod_1.z.string();
15
+ case "Integer":
16
+ return zod_1.z.number();
17
+ default:
18
+ return zod_1.z.string();
19
+ }
20
+ }
21
+ /**
22
+ * Session handler for MCP server.
23
+ * Rejects or approves incoming requests based on existing MCP session header ID
24
+ */
25
+ async function handleMcpSessionRequest(req, res, sessions) {
26
+ const sessionIdHeader = req.headers[constants_1.MCP_SESSION_HEADER];
27
+ if (!sessionIdHeader || !sessions.has(sessionIdHeader)) {
28
+ res.status(400).send("Invalid or missing session ID");
29
+ return;
30
+ }
31
+ const session = sessions.get(sessionIdHeader);
32
+ if (!session) {
33
+ res.status(400).send("Invalid session");
34
+ return;
35
+ }
36
+ await session.transport.handleRequest(req, res);
37
+ }
38
+ function writeODataDescriptionForResource(model) {
39
+ let description = `${model.description}.${constants_1.NEW_LINE}`;
40
+ description += `Should be queried using OData v4 query style using the following allowed parameters.${constants_1.NEW_LINE}`;
41
+ description += `Parameters: ${constants_1.NEW_LINE}`;
42
+ if (model.functionalities.has("filter")) {
43
+ description += `- filter: OData $filter syntax (e.g., "$filter=author_name eq 'Stephen King'")${constants_1.NEW_LINE}`;
44
+ }
45
+ if (model.functionalities.has("top")) {
46
+ description += `- top: OData $top syntax (e.g., $top=10)${constants_1.NEW_LINE}`;
47
+ }
48
+ if (model.functionalities.has("skip")) {
49
+ description += `- skip: OData $skip syntax (e.g., $skip=10)${constants_1.NEW_LINE}`;
50
+ }
51
+ if (model.functionalities.has("select")) {
52
+ description += `- select: OData $select syntax (e.g., $select=property1,property2, etc..)${constants_1.NEW_LINE}`;
53
+ }
54
+ if (model.functionalities.has("orderby")) {
55
+ description += `- orderby: OData $orderby syntax (e.g., "$orderby=property1 asc", or "$orderby=property1 desc")${constants_1.NEW_LINE}`;
56
+ }
57
+ description += `${constants_1.NEW_LINE}Available properties on ${model.target}: ${constants_1.NEW_LINE}`;
58
+ for (const [key, type] of model.properties.entries()) {
59
+ description += `- ${key} -> value type = ${type} ${constants_1.NEW_LINE}`;
60
+ }
61
+ return description;
62
+ }
package/lib/mcp.js ADDED
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
7
+ const logger_1 = require("./logger");
8
+ const crypto_1 = require("crypto");
9
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
10
+ const express_1 = __importDefault(require("express"));
11
+ const parser_1 = require("./annotations/parser");
12
+ const utils_1 = require("./mcp/utils");
13
+ const factory_1 = require("./mcp/factory");
14
+ const constants_1 = require("./mcp/constants");
15
+ const loader_1 = require("./config/loader");
16
+ /* @ts-ignore */
17
+ const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
18
+ // TODO: Handle auth
19
+ class McpPlugin {
20
+ sessions;
21
+ config;
22
+ expressApp;
23
+ annotations;
24
+ constructor() {
25
+ logger_1.LOGGER.debug("Plugin instance created");
26
+ this.sessions = new Map();
27
+ this.config = (0, loader_1.loadConfiguration)();
28
+ }
29
+ async onBootstrap(app) {
30
+ logger_1.LOGGER.debug("Event received for 'bootstrap'");
31
+ this.expressApp = app;
32
+ this.expressApp.use(express_1.default.json());
33
+ logger_1.LOGGER.debug("WHAT IS AUTH?", cds.env.requires.auth);
34
+ logger_1.LOGGER.debug("WHAT IS THIS?!", cds.middlewares.auth);
35
+ await this.registerApiEndpoints();
36
+ logger_1.LOGGER.debug("Bootstrap complete");
37
+ }
38
+ async onLoaded(model) {
39
+ logger_1.LOGGER.debug("Event received for 'loaded'");
40
+ this.annotations = (0, parser_1.parseDefinitions)(model);
41
+ logger_1.LOGGER.debug("Annotations have been loaded");
42
+ }
43
+ async onShutdown() {
44
+ logger_1.LOGGER.debug("Gracefully shutting down MCP server");
45
+ for (const session of this.sessions.values()) {
46
+ await session.server.close();
47
+ }
48
+ logger_1.LOGGER.debug("MCP server sessions has been shutdown");
49
+ }
50
+ async registerApiEndpoints() {
51
+ logger_1.LOGGER.debug("Registering health endpoint for MCP");
52
+ this.expressApp?.get("/mcp/health", (_, res) => {
53
+ res.json({
54
+ status: "UP",
55
+ });
56
+ });
57
+ logger_1.LOGGER.debug("Registering MCP entry point");
58
+ this.expressApp?.post("/mcp", async (req, res) => {
59
+ const sessionIdHeader = req.headers[constants_1.MCP_SESSION_HEADER];
60
+ let sessionEntry = undefined;
61
+ if (sessionIdHeader && this.sessions.has(sessionIdHeader)) {
62
+ logger_1.LOGGER.debug("Request received - Session ID", sessionIdHeader);
63
+ sessionEntry = this.sessions.get(sessionIdHeader);
64
+ }
65
+ else if (!sessionIdHeader && (0, types_js_1.isInitializeRequest)(req.body)) {
66
+ logger_1.LOGGER.debug("Initialize session request received");
67
+ const server = (0, factory_1.createMcpServer)(this.config, this.annotations);
68
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
69
+ sessionIdGenerator: () => (0, crypto_1.randomUUID)(),
70
+ onsessioninitialized: (sid) => {
71
+ this.sessions.set(sid, {
72
+ server: server,
73
+ transport: transport,
74
+ });
75
+ },
76
+ });
77
+ transport.onclose = () => {
78
+ if (!transport.sessionId || !this.sessions.has(transport.sessionId))
79
+ return;
80
+ this.sessions.delete(transport.sessionId);
81
+ };
82
+ await server.connect(transport);
83
+ sessionEntry = {
84
+ server: server,
85
+ transport: transport,
86
+ };
87
+ }
88
+ else {
89
+ res.status(400).json({
90
+ jsonrpc: "2.0",
91
+ error: {
92
+ code: -32000,
93
+ message: "Bad Request: No valid sessions ID provided",
94
+ id: null,
95
+ },
96
+ });
97
+ }
98
+ await sessionEntry?.transport.handleRequest(req, res, req.body);
99
+ });
100
+ this.expressApp?.get("/mcp", (req, res) => (0, utils_1.handleMcpSessionRequest)(req, res, this.sessions));
101
+ this.expressApp?.delete("/mcp", (req, res) => (0, utils_1.handleMcpSessionRequest)(req, res, this.sessions));
102
+ }
103
+ }
104
+ exports.default = McpPlugin;
package/lib/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
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 ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@gavdi/cap-mcp",
3
+ "version": "0.9.0",
4
+ "description": "MCP Pluging for CAP",
5
+ "keywords": [
6
+ "MCP",
7
+ "CAP",
8
+ "plugin",
9
+ "SAP"
10
+ ],
11
+ "license": "Apache-2.0",
12
+ "author": "Simon Vestergaard Laursen",
13
+ "type": "commonjs",
14
+ "main": "cds-plugin.js",
15
+ "files": [
16
+ "lib/**/*",
17
+ "package.json",
18
+ "cds-plugin.js",
19
+ "index.cds",
20
+ "LICENSE.md"
21
+ ],
22
+ "repository": "github:gavdilabs/cap-mcp-plugin",
23
+ "scripts": {
24
+ "mock": "npm run start --workspace=test/demo",
25
+ "test": "jest",
26
+ "build": "tsc",
27
+ "inspect": "npx @modelcontextprotocol/inspector"
28
+ },
29
+ "peerDependencies": {
30
+ "@sap/cds": "^9",
31
+ "express": "^4"
32
+ },
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.13.0",
35
+ "zod": "^3.25.67",
36
+ "zod-to-json-schema": "^3.24.5"
37
+ },
38
+ "devDependencies": {
39
+ "@cap-js/cds-types": "^0.11.0",
40
+ "@types/express": "^5.0.3",
41
+ "@types/node": "^24.0.3",
42
+ "eslint": "^9.29.0",
43
+ "husky": "^9.1.7",
44
+ "jest": "^29.7.0",
45
+ "lint-staged": "^16.1.2",
46
+ "prettier": "^3.5.3",
47
+ "release-it": "^19.0.3",
48
+ "ts-jest": "^29.4.0",
49
+ "typescript": "^5.8.3"
50
+ },
51
+ "lint-staged": {
52
+ "*.json": [
53
+ "prettier --check"
54
+ ],
55
+ "*.ts": [
56
+ "eslint",
57
+ "prettier --check ./src"
58
+ ],
59
+ "*.js": [
60
+ "eslint",
61
+ "prettier --check ./src"
62
+ ]
63
+ },
64
+ "workspaces": [
65
+ "test/demo"
66
+ ]
67
+ }