@gavdi/cap-mcp 0.9.0 → 0.9.2
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/lib/annotations/constants.js +22 -4
- package/lib/annotations/parser.js +41 -3
- package/lib/annotations/structures.js +111 -0
- package/lib/annotations/utils.js +55 -0
- package/lib/config/env-sanitizer.js +78 -0
- package/lib/config/json-parser.js +165 -0
- package/lib/config/loader.js +20 -12
- package/lib/logger.js +8 -0
- package/lib/mcp/constants.js +16 -0
- package/lib/mcp/custom-resource-template.js +156 -0
- package/lib/mcp/customResourceTemplate.js +156 -0
- package/lib/mcp/factory.js +21 -17
- package/lib/mcp/prompts.js +12 -2
- package/lib/mcp/resources.js +89 -25
- package/lib/mcp/session-manager.js +92 -0
- package/lib/mcp/tools.js +82 -13
- package/lib/mcp/utils.js +12 -3
- package/lib/mcp/validation.js +318 -0
- package/lib/mcp.js +70 -42
- package/package.json +18 -6
package/lib/config/loader.js
CHANGED
@@ -2,6 +2,8 @@
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
3
3
|
exports.loadConfiguration = loadConfiguration;
|
4
4
|
const logger_1 = require("../logger");
|
5
|
+
const env_sanitizer_1 = require("./env-sanitizer");
|
6
|
+
const json_parser_1 = require("./json-parser");
|
5
7
|
/* @ts-ignore */
|
6
8
|
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
7
9
|
const ENV_NPM_PACKAGE_NAME = "npm_package_name";
|
@@ -10,6 +12,10 @@ const DEFAULT_PROJECT_INFO = {
|
|
10
12
|
name: "cap-mcp-server",
|
11
13
|
version: "1.0.0",
|
12
14
|
};
|
15
|
+
/**
|
16
|
+
* Loads CAP configuration from environment and CDS settings
|
17
|
+
* @returns Complete CAP configuration object with defaults applied
|
18
|
+
*/
|
13
19
|
function loadConfiguration() {
|
14
20
|
const packageInfo = getProjectInfo();
|
15
21
|
const cdsEnv = loadCdsEnvConfiguration();
|
@@ -24,17 +30,15 @@ function loadConfiguration() {
|
|
24
30
|
};
|
25
31
|
}
|
26
32
|
/**
|
27
|
-
*
|
28
|
-
*
|
29
|
-
*
|
30
|
-
* In case of an error, the project info will default to plugin defaults.
|
31
|
-
* See constants for reference.
|
33
|
+
* Extracts project information from environment variables with fallback to defaults
|
34
|
+
* Uses npm package environment variables to identify the hosting CAP application
|
35
|
+
* @returns Project information object with name and version
|
32
36
|
*/
|
33
37
|
function getProjectInfo() {
|
34
38
|
try {
|
35
39
|
return {
|
36
|
-
name:
|
37
|
-
version:
|
40
|
+
name: (0, env_sanitizer_1.getSafeEnvVar)(ENV_NPM_PACKAGE_NAME, DEFAULT_PROJECT_INFO.name),
|
41
|
+
version: (0, env_sanitizer_1.getSafeEnvVar)(ENV_NPM_PACKAGE_VERSION, DEFAULT_PROJECT_INFO.version),
|
38
42
|
};
|
39
43
|
}
|
40
44
|
catch (e) {
|
@@ -42,17 +46,21 @@ function getProjectInfo() {
|
|
42
46
|
return DEFAULT_PROJECT_INFO;
|
43
47
|
}
|
44
48
|
}
|
49
|
+
/**
|
50
|
+
* Loads CDS environment configuration from cds.env.mcp
|
51
|
+
* @returns CAP configuration object or undefined if not found/invalid
|
52
|
+
*/
|
45
53
|
function loadCdsEnvConfiguration() {
|
46
54
|
const config = cds.env.mcp;
|
47
55
|
if (!config)
|
48
56
|
return undefined;
|
49
57
|
else if (typeof config === "object")
|
50
58
|
return config;
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
logger_1.LOGGER.warn("Could not parse the configuration object from cdsrc");
|
59
|
+
// Use secure JSON parser for string configurations
|
60
|
+
const parsed = (0, json_parser_1.parseCAPConfiguration)(config);
|
61
|
+
if (!parsed) {
|
62
|
+
logger_1.LOGGER.warn((0, json_parser_1.createSafeErrorMessage)("CDS environment configuration"));
|
56
63
|
return undefined;
|
57
64
|
}
|
65
|
+
return parsed;
|
58
66
|
}
|
package/lib/logger.js
CHANGED
@@ -1,6 +1,14 @@
|
|
1
1
|
"use strict";
|
2
|
+
/**
|
3
|
+
* Logger instance for the CDS MCP plugin
|
4
|
+
* Uses CAP's built-in logging system with "cds-mcp" namespace
|
5
|
+
*/
|
2
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
3
7
|
exports.LOGGER = void 0;
|
4
8
|
/* @ts-ignore */
|
5
9
|
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
10
|
+
/**
|
11
|
+
* Shared logger instance for all MCP plugin components
|
12
|
+
* Provides debug, info, warn, and error logging methods
|
13
|
+
*/
|
6
14
|
exports.LOGGER = cds.log("cds-mcp");
|
package/lib/mcp/constants.js
CHANGED
@@ -1,6 +1,22 @@
|
|
1
1
|
"use strict";
|
2
|
+
/**
|
3
|
+
* Constants used throughout the MCP (Model Context Protocol) implementation
|
4
|
+
* Defines error messages, HTTP headers, and formatting constants
|
5
|
+
*/
|
2
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
3
7
|
exports.NEW_LINE = exports.MCP_SESSION_HEADER = exports.ERR_MISSING_SERVICE = void 0;
|
8
|
+
/**
|
9
|
+
* Standard error message returned when a CAP service cannot be found or accessed
|
10
|
+
* Used in tool execution when the target service is unavailable
|
11
|
+
*/
|
4
12
|
exports.ERR_MISSING_SERVICE = "Error: Service could not be found";
|
13
|
+
/**
|
14
|
+
* HTTP header name used to identify MCP session IDs in requests
|
15
|
+
* Client must include this header with a valid session ID for authenticated requests
|
16
|
+
*/
|
5
17
|
exports.MCP_SESSION_HEADER = "mcp-session-id";
|
18
|
+
/**
|
19
|
+
* Newline character constant used for consistent text formatting
|
20
|
+
* Used in resource descriptions and error message formatting
|
21
|
+
*/
|
6
22
|
exports.NEW_LINE = "\n";
|
@@ -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 for better type safety
|
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;
|
@@ -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/factory.js
CHANGED
@@ -7,6 +7,12 @@ const structures_1 = require("../annotations/structures");
|
|
7
7
|
const tools_1 = require("./tools");
|
8
8
|
const resources_1 = require("./resources");
|
9
9
|
const prompts_1 = require("./prompts");
|
10
|
+
/**
|
11
|
+
* Creates and configures an MCP server instance with the given configuration and annotations
|
12
|
+
* @param config - CAP configuration object
|
13
|
+
* @param annotations - Optional parsed annotations to register with the server
|
14
|
+
* @returns Configured MCP server instance
|
15
|
+
*/
|
10
16
|
function createMcpServer(config, annotations) {
|
11
17
|
logger_1.LOGGER.debug("Creating MCP server instance");
|
12
18
|
const server = new mcp_js_1.McpServer({
|
@@ -14,28 +20,26 @@ function createMcpServer(config, annotations) {
|
|
14
20
|
version: config.version,
|
15
21
|
capabilities: config.capabilities,
|
16
22
|
});
|
17
|
-
if (!annotations)
|
23
|
+
if (!annotations) {
|
24
|
+
logger_1.LOGGER.debug("No annotations provided, skipping registration...");
|
18
25
|
return server;
|
26
|
+
}
|
19
27
|
logger_1.LOGGER.debug("Annotations found for server: ", annotations);
|
20
|
-
// TODO: Handle the parsed annotations
|
21
|
-
// TODO: Error handling
|
22
|
-
// TODO: This should only be mapped once, not per each server instance. Maybe this should be pre-packaged on load?
|
23
28
|
// TODO: Handle auth
|
24
29
|
for (const entry of annotations.values()) {
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
logger_1.LOGGER.warn("Invalid annotation entry - Cannot be parsed by MCP server, skipping...");
|
37
|
-
continue;
|
30
|
+
if (entry instanceof structures_1.McpToolAnnotation) {
|
31
|
+
(0, tools_1.assignToolToServer)(entry, server);
|
32
|
+
continue;
|
33
|
+
}
|
34
|
+
else if (entry instanceof structures_1.McpResourceAnnotation) {
|
35
|
+
(0, resources_1.assignResourceToServer)(entry, server);
|
36
|
+
continue;
|
37
|
+
}
|
38
|
+
else if (entry instanceof structures_1.McpPromptAnnotation) {
|
39
|
+
(0, prompts_1.assignPromptToServer)(entry, server);
|
40
|
+
continue;
|
38
41
|
}
|
42
|
+
logger_1.LOGGER.warn("Invalid annotation entry - Cannot be parsed by MCP server, skipping...");
|
39
43
|
}
|
40
44
|
return server;
|
41
45
|
}
|
package/lib/mcp/prompts.js
CHANGED
@@ -3,8 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.assignPromptToServer = assignPromptToServer;
|
4
4
|
const logger_1 = require("../logger");
|
5
5
|
const utils_1 = require("./utils");
|
6
|
-
/* @ts-ignore */
|
7
|
-
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
8
6
|
// NOTE: Not satisfied with below implementation, will need to be revised for full effect
|
9
7
|
/*
|
10
8
|
annotate CatalogService with @mcp.prompts: [{
|
@@ -18,6 +16,12 @@ annotate CatalogService with @mcp.prompts: [{
|
|
18
16
|
}]
|
19
17
|
}];
|
20
18
|
*/
|
19
|
+
/**
|
20
|
+
* Registers prompt templates from a prompt annotation with the MCP server
|
21
|
+
* Each prompt template supports variable substitution using {{variable}} syntax
|
22
|
+
* @param model - The prompt annotation containing template definitions and inputs
|
23
|
+
* @param server - The MCP server instance to register prompts with
|
24
|
+
*/
|
21
25
|
function assignPromptToServer(model, server) {
|
22
26
|
logger_1.LOGGER.debug("Adding prompt", model);
|
23
27
|
for (const prompt of model.prompts) {
|
@@ -45,6 +49,12 @@ function assignPromptToServer(model, server) {
|
|
45
49
|
});
|
46
50
|
}
|
47
51
|
}
|
52
|
+
/**
|
53
|
+
* Builds Zod schema definitions for prompt input parameters
|
54
|
+
* Converts CDS type strings to appropriate Zod validation schemas
|
55
|
+
* @param inputs - Array of prompt input parameter definitions
|
56
|
+
* @returns Record mapping parameter names to Zod schemas, or undefined if no inputs
|
57
|
+
*/
|
48
58
|
function constructInputArgs(inputs) {
|
49
59
|
// Not happy with using any here, but zod types are hard to figure out....
|
50
60
|
if (!inputs || inputs.length <= 0)
|
package/lib/mcp/resources.js
CHANGED
@@ -1,45 +1,87 @@
|
|
1
1
|
"use strict";
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
3
3
|
exports.assignResourceToServer = assignResourceToServer;
|
4
|
-
const
|
4
|
+
const custom_resource_template_1 = require("./custom-resource-template");
|
5
5
|
const logger_1 = require("../logger");
|
6
6
|
const utils_1 = require("./utils");
|
7
|
+
const validation_1 = require("./validation");
|
7
8
|
// import cds from "@sap/cds";
|
8
9
|
/* @ts-ignore */
|
9
10
|
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
11
|
+
/**
|
12
|
+
* Registers a CAP entity as an MCP resource with optional OData query support
|
13
|
+
* Creates either static or dynamic resources based on configured functionalities
|
14
|
+
* @param model - The resource annotation containing entity metadata and query options
|
15
|
+
* @param server - The MCP server instance to register the resource with
|
16
|
+
*/
|
10
17
|
function assignResourceToServer(model, server) {
|
11
18
|
logger_1.LOGGER.debug("Adding resource", model);
|
12
19
|
if (model.functionalities.size <= 0) {
|
13
20
|
registerStaticResource(model, server);
|
21
|
+
return;
|
14
22
|
}
|
15
23
|
// Dynamic resource registration
|
16
24
|
const detailedDescription = (0, utils_1.writeODataDescriptionForResource)(model);
|
17
|
-
const functionalities = Array.from(model.functionalities)
|
18
|
-
//
|
19
|
-
|
20
|
-
const
|
25
|
+
const functionalities = Array.from(model.functionalities);
|
26
|
+
// Using grouped query parameter format to fix MCP SDK URI matching issue
|
27
|
+
// Format: {?param1,param2,param3} instead of {?param1}{?param2}{?param3}
|
28
|
+
const templateParams = functionalities.length > 0 ? `{?${functionalities.join(",")}}` : "";
|
29
|
+
const resourceTemplateUri = `odata://${model.serviceName}/${model.name}${templateParams}`;
|
30
|
+
const template = new custom_resource_template_1.CustomResourceTemplate(resourceTemplateUri, {
|
21
31
|
list: undefined,
|
22
32
|
});
|
23
|
-
server.registerResource(model.name, template,
|
33
|
+
server.registerResource(model.name, template, // Type assertion to bypass strict type checking - necessary due to broken URI parser in the MCP SDK
|
34
|
+
{ title: model.target, description: detailedDescription }, async (uri, variables) => {
|
35
|
+
const queryParameters = variables;
|
24
36
|
const service = cds.services[model.serviceName];
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
37
|
+
if (!service) {
|
38
|
+
logger_1.LOGGER.error(`Invalid service found for service '${model.serviceName}'`);
|
39
|
+
throw new Error(`Invalid service found for service '${model.serviceName}'`);
|
40
|
+
}
|
41
|
+
// Create validator with entity properties
|
42
|
+
const validator = new validation_1.ODataQueryValidator(model.properties);
|
43
|
+
// Validate and build query with secure parameter handling
|
44
|
+
let query;
|
45
|
+
try {
|
46
|
+
query = SELECT.from(model.target).limit(queryParameters.top
|
47
|
+
? validator.validateTop(queryParameters.top)
|
48
|
+
: 100, queryParameters.skip
|
49
|
+
? validator.validateSkip(queryParameters.skip)
|
50
|
+
: undefined);
|
51
|
+
for (const [k, v] of Object.entries(queryParameters)) {
|
52
|
+
if (!v || v.trim().length <= 0)
|
40
53
|
continue;
|
54
|
+
switch (k) {
|
55
|
+
case "filter":
|
56
|
+
// BUG: If filter value is e.g. "filter=1234" the value 1234 will go through
|
57
|
+
const validatedFilter = validator.validateFilter(v);
|
58
|
+
const expression = cds.parse.expr(validatedFilter);
|
59
|
+
query.where(expression);
|
60
|
+
continue;
|
61
|
+
case "select":
|
62
|
+
const validatedColumns = validator.validateSelect(v);
|
63
|
+
query.columns(validatedColumns);
|
64
|
+
continue;
|
65
|
+
case "orderby":
|
66
|
+
const validatedOrderBy = validator.validateOrderBy(v);
|
67
|
+
query.orderBy(validatedOrderBy);
|
68
|
+
continue;
|
69
|
+
default:
|
70
|
+
continue;
|
71
|
+
}
|
41
72
|
}
|
42
73
|
}
|
74
|
+
catch (error) {
|
75
|
+
logger_1.LOGGER.warn(`OData query validation failed for ${model.target}:`, error);
|
76
|
+
return {
|
77
|
+
contents: [
|
78
|
+
{
|
79
|
+
uri: uri.href,
|
80
|
+
text: `ERROR: Invalid query parameter - ${error instanceof validation_1.ODataValidationError ? error.message : "Invalid query syntax"}`,
|
81
|
+
},
|
82
|
+
],
|
83
|
+
};
|
84
|
+
}
|
43
85
|
try {
|
44
86
|
const response = await service.run(query);
|
45
87
|
return {
|
@@ -64,11 +106,22 @@ function assignResourceToServer(model, server) {
|
|
64
106
|
}
|
65
107
|
});
|
66
108
|
}
|
109
|
+
/**
|
110
|
+
* Registers a static resource without OData query functionality
|
111
|
+
* Used when no query functionalities are configured for the resource
|
112
|
+
* @param model - The resource annotation with entity metadata
|
113
|
+
* @param server - The MCP server instance to register with
|
114
|
+
*/
|
67
115
|
function registerStaticResource(model, server) {
|
68
|
-
server.registerResource(model.name, `odata://${model.serviceName}/${model.name}`, { title: model.target, description: model.description }, async (uri,
|
116
|
+
server.registerResource(model.name, `odata://${model.serviceName}/${model.name}`, { title: model.target, description: model.description }, async (uri, extra) => {
|
117
|
+
const queryParameters = extra;
|
69
118
|
const service = cds.services[model.serviceName];
|
70
|
-
|
119
|
+
// Create validator even for static resources to validate top parameter
|
120
|
+
const validator = new validation_1.ODataQueryValidator(model.properties);
|
71
121
|
try {
|
122
|
+
const query = SELECT.from(model.target).limit(queryParameters.top
|
123
|
+
? validator.validateTop(queryParameters.top)
|
124
|
+
: 100);
|
72
125
|
const response = await service.run(query);
|
73
126
|
return {
|
74
127
|
contents: [
|
@@ -79,8 +132,19 @@ function registerStaticResource(model, server) {
|
|
79
132
|
],
|
80
133
|
};
|
81
134
|
}
|
82
|
-
catch (
|
83
|
-
|
135
|
+
catch (error) {
|
136
|
+
if (error instanceof validation_1.ODataValidationError) {
|
137
|
+
logger_1.LOGGER.warn(`OData validation failed for static resource ${model.target}:`, error);
|
138
|
+
return {
|
139
|
+
contents: [
|
140
|
+
{
|
141
|
+
uri: uri.href,
|
142
|
+
text: `ERROR: Invalid query parameter - ${error.message}`,
|
143
|
+
},
|
144
|
+
],
|
145
|
+
};
|
146
|
+
}
|
147
|
+
logger_1.LOGGER.error(`Failed to retrieve resource data for ${model.target}`, error);
|
84
148
|
return {
|
85
149
|
contents: [
|
86
150
|
{
|