@gavdi/cap-mcp 0.9.9-alpha.3 → 0.10.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.
- package/README.md +48 -18
- package/lib/annotations/constants.js +2 -0
- package/lib/annotations/parser.js +18 -2
- package/lib/annotations/structures.js +12 -1
- package/lib/annotations/utils.js +16 -0
- package/lib/auth/handler.js +1 -13
- package/lib/auth/utils.js +51 -79
- package/lib/mcp/describe-model.js +6 -2
- package/lib/mcp/elicited-input.js +162 -0
- package/lib/mcp/entity-tools.js +98 -21
- package/lib/mcp/tools.js +20 -25
- package/lib/mcp/utils.js +2 -0
- package/lib/mcp.js +1 -3
- package/package.json +2 -2
- package/lib/.DS_Store +0 -0
- 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
|
@@ -1,156 +0,0 @@
|
|
|
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/types.js
DELETED
package/lib/utils.js
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
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
|
-
}
|