soajs.controller 4.3.21 → 4.3.23
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/ARCHITECTURE.md +2 -1
- package/docs/configuration-custom-registry.md +30 -0
- package/docs/gateway-routes.md +19 -1
- package/docs/mcp.md +140 -0
- package/mw/gotoService/index.js +11 -0
- package/mw/mt/index.js +3 -1
- package/mw/soajsRoutes/keyACL/index.js +11 -87
- package/mw/soajsRoutes/lib/acl.js +130 -0
- package/mw/soajsRoutes/mcp/index.js +182 -0
- package/mw/soajsRoutes/mcp/tools.js +435 -0
- package/package.json +10 -9
- package/test/data/provision/customRegistry/gateway.js +4 -0
- package/test/integration/UC2-http-methods/service-mock/index.js +29 -0
- package/test/integration/UC8-mcp/index.js +157 -0
- package/test/integration/index.js +4 -0
- package/test/unit/index.js +2 -0
- package/test/unit/mw/soajsRoutes/keyACL.js +105 -0
- package/test/unit/mw/soajsRoutes/mcp.js +572 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @license
|
|
5
|
+
* Copyright SOAJS All Rights Reserved.
|
|
6
|
+
*
|
|
7
|
+
* Use of this source code is governed by an Apache license that can be
|
|
8
|
+
* found in the LICENSE file at the root of this repository
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* POST /soajs/mcp: MCP over HTTP (JSON-RPC 2.0, one JSON answer per request, no session).
|
|
13
|
+
* An agent lists the APIs its caller may use as tools and calls them; a tool call is processed
|
|
14
|
+
* as the API call it stands for. On only when registry.custom.gateway.value.mcp.enabled is true.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const aclLib = require("../lib/acl.js");
|
|
18
|
+
const tools = require("./tools.js");
|
|
19
|
+
|
|
20
|
+
const MAX_BODY = 1024 * 1024;
|
|
21
|
+
const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
22
|
+
|
|
23
|
+
const ERROR = {
|
|
24
|
+
"parse": -32700,
|
|
25
|
+
"invalidRequest": -32600,
|
|
26
|
+
"methodNotFound": -32601,
|
|
27
|
+
"invalidParams": -32602,
|
|
28
|
+
"internal": -32603
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
let gatewayVersion = "0";
|
|
32
|
+
try {
|
|
33
|
+
gatewayVersion = require("../../../package.json").version;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
gatewayVersion = "0";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function send(res, status, body, headers) {
|
|
39
|
+
let text = (body === null || body === undefined) ? "" : JSON.stringify(body);
|
|
40
|
+
let h = Object.assign({}, headers || {});
|
|
41
|
+
if (text) {
|
|
42
|
+
h["Content-Type"] = "application/json";
|
|
43
|
+
}
|
|
44
|
+
h["Content-Length"] = Buffer.byteLength(text);
|
|
45
|
+
res.writeHead(status, h);
|
|
46
|
+
res.end(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rpcError(id, code, message) {
|
|
50
|
+
return {"jsonrpc": "2.0", "id": (id === undefined ? null : id), "error": {"code": code, "message": message}};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rpcResult(id, result) {
|
|
54
|
+
return {"jsonrpc": "2.0", "id": id, "result": result};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readBody(req) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let chunks = [];
|
|
60
|
+
let size = 0;
|
|
61
|
+
let done = false;
|
|
62
|
+
req.on("data", (chunk) => {
|
|
63
|
+
if (done) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
size += chunk.length;
|
|
67
|
+
if (size > MAX_BODY) {
|
|
68
|
+
done = true;
|
|
69
|
+
return reject(new Error("too large"));
|
|
70
|
+
}
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
});
|
|
73
|
+
req.on("end", () => {
|
|
74
|
+
if (!done) {
|
|
75
|
+
done = true;
|
|
76
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
req.on("error", (err) => {
|
|
80
|
+
if (!done) {
|
|
81
|
+
done = true;
|
|
82
|
+
reject(err);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = (configuration) => {
|
|
89
|
+
let core = configuration.core;
|
|
90
|
+
|
|
91
|
+
let handle = (req, res, config, message) => {
|
|
92
|
+
let id = message.id;
|
|
93
|
+
let params = (message.params && typeof message.params === "object") ? message.params : {};
|
|
94
|
+
switch (message.method) {
|
|
95
|
+
case "initialize": {
|
|
96
|
+
let requested = params.protocolVersion;
|
|
97
|
+
return Promise.resolve(rpcResult(id, {
|
|
98
|
+
"protocolVersion": (PROTOCOL_VERSIONS.indexOf(requested) !== -1) ? requested : PROTOCOL_VERSIONS[0],
|
|
99
|
+
"capabilities": {"tools": {"listChanged": false}},
|
|
100
|
+
"serverInfo": {"name": "soajs-gateway", "version": gatewayVersion},
|
|
101
|
+
"instructions": "The tools are the APIs this tenant's package and the signed-in user's ACL allow. A tool call is processed as the API call it stands for."
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
case "ping":
|
|
105
|
+
return Promise.resolve(rpcResult(id, {}));
|
|
106
|
+
case "tools/list":
|
|
107
|
+
return tools.listTools(req, configuration, config).then((list) => rpcResult(id, {"tools": list.tools}));
|
|
108
|
+
case "tools/call": {
|
|
109
|
+
if (typeof params.name !== "string") {
|
|
110
|
+
return Promise.resolve(rpcError(id, ERROR.invalidParams, "params.name is required"));
|
|
111
|
+
}
|
|
112
|
+
return tools.listTools(req, configuration, config).then((list) => {
|
|
113
|
+
let entry = list.index[params.name];
|
|
114
|
+
if (!entry) {
|
|
115
|
+
return rpcError(id, ERROR.invalidParams, "Unknown tool: " + params.name);
|
|
116
|
+
}
|
|
117
|
+
return tools.callTool(req, configuration, config, entry, params.arguments)
|
|
118
|
+
.then((result) => rpcResult(id, result));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
default:
|
|
122
|
+
return Promise.resolve(rpcError(id, ERROR.methodNotFound, "Method not found: " + message.method));
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
let return_SOAJS_mcp = (req, res) => {
|
|
127
|
+
let config = tools.getConfig(req);
|
|
128
|
+
if (!config) {
|
|
129
|
+
return req.soajs.controllerResponse({
|
|
130
|
+
"status": 404,
|
|
131
|
+
"code": 190,
|
|
132
|
+
"msg": "MCP is not enabled in this environment."
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (req.method !== "POST") {
|
|
136
|
+
return send(res, 405, rpcError(null, ERROR.invalidRequest, "Use POST"), {"Allow": "POST"});
|
|
137
|
+
}
|
|
138
|
+
readBody(req).then((text) => {
|
|
139
|
+
let message;
|
|
140
|
+
try {
|
|
141
|
+
message = JSON.parse(text);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return send(res, 400, rpcError(null, ERROR.parse, "Parse error"));
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(message)) {
|
|
146
|
+
return send(res, 400, rpcError(null, ERROR.invalidRequest, "Batches are not supported"));
|
|
147
|
+
}
|
|
148
|
+
if (!message || typeof message !== "object" || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
|
|
149
|
+
return send(res, 400, rpcError(message && message.id, ERROR.invalidRequest, "Invalid request"));
|
|
150
|
+
}
|
|
151
|
+
// a notification, or a response from the client: nothing to answer
|
|
152
|
+
if (!Object.hasOwnProperty.call(message, "id")) {
|
|
153
|
+
return send(res, 202, null);
|
|
154
|
+
}
|
|
155
|
+
return handle(req, res, config, message)
|
|
156
|
+
.then((answer) => send(res, 200, answer))
|
|
157
|
+
.catch((err) => {
|
|
158
|
+
req.soajs.log.error("MCP: " + message.method + " failed: " + err.message);
|
|
159
|
+
return send(res, 200, rpcError(message.id, ERROR.internal, "Internal error"));
|
|
160
|
+
});
|
|
161
|
+
}).catch(() => {
|
|
162
|
+
return send(res, 413, rpcError(null, ERROR.invalidRequest, "Request larger than " + MAX_BODY + " bytes"));
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return (req, res, next) => {
|
|
167
|
+
//doesn't work without a key in the headers
|
|
168
|
+
if (!req.headers || !req.headers.key) {
|
|
169
|
+
return req.soajs.controllerResponse(core.error.getError(132));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
//mimic an api of the gateway, so mt runs the key, oauth and urac checks
|
|
173
|
+
aclLib.mimicGatewayApi(req, configuration, {
|
|
174
|
+
"l": "MCP",
|
|
175
|
+
"v": "/soajs/mcp",
|
|
176
|
+
"m": "post"
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
req.soajs.controller.gotoservice = return_SOAJS_mcp;
|
|
180
|
+
return next();
|
|
181
|
+
};
|
|
182
|
+
};
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @license
|
|
5
|
+
* Copyright SOAJS All Rights Reserved.
|
|
6
|
+
*
|
|
7
|
+
* Use of this source code is governed by an Apache license that can be
|
|
8
|
+
* found in the LICENSE file at the root of this repository
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The tools of /soajs/mcp: the APIs the caller may use, described by each service on its
|
|
13
|
+
* GET /mcp/tools, and the call of one tool as an API call through the gateway.
|
|
14
|
+
*
|
|
15
|
+
* A soajs service answers with its tools (built from its config.js schema); a service built with
|
|
16
|
+
* one of the SDKs answers with its Swagger / OpenAPI document (an object, or YAML text), converted
|
|
17
|
+
* here with soajs.core.libs mcp.fromSwagger. Both are cached per service and version.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const http = require("http");
|
|
21
|
+
const yaml = require("js-yaml");
|
|
22
|
+
const coreLibs = require("soajs.core.libs");
|
|
23
|
+
|
|
24
|
+
const aclLib = require("../lib/acl.js");
|
|
25
|
+
|
|
26
|
+
const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
|
|
27
|
+
|
|
28
|
+
const DEFAULTS = {
|
|
29
|
+
"ttl": 300000, // service tools cache, ms
|
|
30
|
+
"errorTtl": 30000, // a service that did not answer is asked again after, ms
|
|
31
|
+
"timeout": 10000, // fetching tools and calling a tool, ms
|
|
32
|
+
"maxTools": 300
|
|
33
|
+
};
|
|
34
|
+
const MAX_RESPONSE = 5 * 1024 * 1024;
|
|
35
|
+
const NAME_MAX = 64;
|
|
36
|
+
|
|
37
|
+
// service@version -> {expires, tools}
|
|
38
|
+
let cache = {};
|
|
39
|
+
|
|
40
|
+
const HEADERS_TO_FORWARD = ['key', 'authorization', 'access_token', 'device-id', 'user-agent', 'accept-language', 'idempotency-key'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The mcp configuration: registry.custom.gateway.value.mcp
|
|
44
|
+
* {enabled, ttl, timeout, maxTools}
|
|
45
|
+
*/
|
|
46
|
+
function getConfig(req) {
|
|
47
|
+
let config = get(["soajs", "registry", "custom", "gateway", "value", "mcp"], req);
|
|
48
|
+
if (!config || config.enabled !== true) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
let out = {"enabled": true};
|
|
52
|
+
for (let k in DEFAULTS) {
|
|
53
|
+
if (DEFAULTS.hasOwnProperty(k)) {
|
|
54
|
+
out[k] = (typeof config[k] === "number" && config[k] > 0) ? config[k] : DEFAULTS[k];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* GET or POST over http, resolving with {status, body}: unlike httpRequestLight, an error status
|
|
62
|
+
* keeps its body, a tool call returns the api's error to the agent.
|
|
63
|
+
*/
|
|
64
|
+
function request({uri, method = "GET", headers = {}, body = null, timeout}) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
let url = new URL(uri);
|
|
67
|
+
let payload = (body === null || body === undefined) ? null : JSON.stringify(body);
|
|
68
|
+
let reqHeaders = Object.assign({"accept": "application/json"}, headers);
|
|
69
|
+
if (payload !== null) {
|
|
70
|
+
reqHeaders["content-type"] = "application/json";
|
|
71
|
+
reqHeaders["content-length"] = Buffer.byteLength(payload);
|
|
72
|
+
}
|
|
73
|
+
let done = false;
|
|
74
|
+
let finish = (err, value) => {
|
|
75
|
+
if (done) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
done = true;
|
|
79
|
+
if (err) {
|
|
80
|
+
return reject(err);
|
|
81
|
+
}
|
|
82
|
+
return resolve(value);
|
|
83
|
+
};
|
|
84
|
+
let r = http.request({
|
|
85
|
+
"hostname": url.hostname,
|
|
86
|
+
"port": url.port,
|
|
87
|
+
"path": url.pathname + url.search,
|
|
88
|
+
"method": method,
|
|
89
|
+
"headers": reqHeaders
|
|
90
|
+
}, (res) => {
|
|
91
|
+
let chunks = [];
|
|
92
|
+
let size = 0;
|
|
93
|
+
res.on("data", (chunk) => {
|
|
94
|
+
size += chunk.length;
|
|
95
|
+
if (size > MAX_RESPONSE) {
|
|
96
|
+
res.destroy();
|
|
97
|
+
return finish(new Error("Response larger than " + MAX_RESPONSE + " bytes"));
|
|
98
|
+
}
|
|
99
|
+
chunks.push(chunk);
|
|
100
|
+
});
|
|
101
|
+
res.on("end", () => {
|
|
102
|
+
let text = Buffer.concat(chunks).toString("utf8");
|
|
103
|
+
let json = null;
|
|
104
|
+
try {
|
|
105
|
+
json = text ? JSON.parse(text) : null;
|
|
106
|
+
} catch (e) {
|
|
107
|
+
json = null;
|
|
108
|
+
}
|
|
109
|
+
return finish(null, {"status": res.statusCode, "text": text, "json": json});
|
|
110
|
+
});
|
|
111
|
+
res.on("error", (err) => finish(err));
|
|
112
|
+
});
|
|
113
|
+
r.on("error", (err) => finish(err));
|
|
114
|
+
r.setTimeout(timeout, () => {
|
|
115
|
+
r.destroy();
|
|
116
|
+
finish(new Error("Request timed out"));
|
|
117
|
+
});
|
|
118
|
+
if (payload !== null) {
|
|
119
|
+
r.write(payload);
|
|
120
|
+
}
|
|
121
|
+
r.end();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The port a service answers its maintenance routes on, the way awareness checks its heartbeat.
|
|
127
|
+
*/
|
|
128
|
+
function maintenancePort(registry, serviceName) {
|
|
129
|
+
let service = registry.services[serviceName];
|
|
130
|
+
let inc = get(["serviceConfig", "ports", "maintenanceInc"], registry) || 1000;
|
|
131
|
+
let type = get(["maintenance", "port", "type"], service);
|
|
132
|
+
if (type === "inherit") {
|
|
133
|
+
return service.port;
|
|
134
|
+
}
|
|
135
|
+
if (type && type !== "maintenance") {
|
|
136
|
+
let value = parseInt(get(["maintenance", "port", "value"], service));
|
|
137
|
+
if (!isNaN(value)) {
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return service.port + inc;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The tools a service describes, from its answer to GET /mcp/tools.
|
|
146
|
+
*/
|
|
147
|
+
function toolsFromAnswer(answer) {
|
|
148
|
+
let data = (answer && answer.result === true) ? answer.data : null;
|
|
149
|
+
if (!data || typeof data !== "object") {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(data.tools)) {
|
|
153
|
+
return data.tools;
|
|
154
|
+
}
|
|
155
|
+
let doc = data.swagger;
|
|
156
|
+
if (typeof doc === "string") {
|
|
157
|
+
doc = yaml.load(doc);
|
|
158
|
+
}
|
|
159
|
+
if (doc && typeof doc === "object") {
|
|
160
|
+
return coreLibs.mcp.fromSwagger(doc);
|
|
161
|
+
}
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The tools of one service at one version, cached.
|
|
167
|
+
*/
|
|
168
|
+
function serviceTools(req, serviceName, version, config) {
|
|
169
|
+
let key = serviceName + "@" + version;
|
|
170
|
+
let now = Date.now();
|
|
171
|
+
if (cache[key] && cache[key].expires > now) {
|
|
172
|
+
return Promise.resolve(cache[key].tools);
|
|
173
|
+
}
|
|
174
|
+
let registry = req.soajs.registry;
|
|
175
|
+
let service = get(["services", serviceName], registry);
|
|
176
|
+
if (!service || !service.port) {
|
|
177
|
+
return Promise.resolve([]);
|
|
178
|
+
}
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
req.soajs.awareness.getHost(serviceName, version, (host) => {
|
|
181
|
+
if (!host) {
|
|
182
|
+
req.soajs.log.error("MCP: no host for [" + serviceName + "@" + version + "]");
|
|
183
|
+
cache[key] = {"expires": now + config.errorTtl, "tools": []};
|
|
184
|
+
return resolve([]);
|
|
185
|
+
}
|
|
186
|
+
request({
|
|
187
|
+
"uri": "http://" + host + ":" + maintenancePort(registry, serviceName) + "/mcp/tools",
|
|
188
|
+
"timeout": config.timeout
|
|
189
|
+
}).then((response) => {
|
|
190
|
+
let tools = [];
|
|
191
|
+
try {
|
|
192
|
+
tools = (response.status === 200) ? toolsFromAnswer(response.json) : [];
|
|
193
|
+
} catch (e) {
|
|
194
|
+
req.soajs.log.error("MCP: the tools of [" + serviceName + "@" + version + "] could not be read: " + e.message);
|
|
195
|
+
}
|
|
196
|
+
tools = tools.filter((tool) => tool && typeof tool.m === "string" && typeof tool.v === "string");
|
|
197
|
+
cache[key] = {"expires": Date.now() + config.ttl, "tools": tools};
|
|
198
|
+
return resolve(tools);
|
|
199
|
+
}).catch((err) => {
|
|
200
|
+
req.soajs.log.error("MCP: could not get the tools of [" + serviceName + "@" + version + "]: " + err.message);
|
|
201
|
+
cache[key] = {"expires": Date.now() + config.errorTtl, "tools": []};
|
|
202
|
+
return resolve([]);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A tool name agents and model APIs accept: [A-Za-z0-9_-], at most 64 characters, unique.
|
|
210
|
+
*/
|
|
211
|
+
function toolName(serviceName, tool, taken) {
|
|
212
|
+
let base = serviceName + "_" + (tool.operationId || (tool.m + tool.v));
|
|
213
|
+
base = base.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
|
|
214
|
+
base = base.substring(0, NAME_MAX);
|
|
215
|
+
let name = base;
|
|
216
|
+
let n = 2;
|
|
217
|
+
while (taken[name]) {
|
|
218
|
+
let suffix = "_" + n;
|
|
219
|
+
name = base.substring(0, NAME_MAX - suffix.length) + suffix;
|
|
220
|
+
n++;
|
|
221
|
+
}
|
|
222
|
+
taken[name] = true;
|
|
223
|
+
return name;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Whether the caller's ACL allows the api: a restricted service lists its apis per method.
|
|
228
|
+
*/
|
|
229
|
+
function allowed(serviceACL, tool) {
|
|
230
|
+
let methods = ["get", "post", "put", "delete", "del"];
|
|
231
|
+
let restricted = methods.some((m) => Array.isArray(serviceACL[m]));
|
|
232
|
+
if (!restricted) {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
let list = serviceACL[tool.m] || (tool.m === "delete" ? serviceACL.del : null);
|
|
236
|
+
return Array.isArray(list) && list.indexOf(tool.v) !== -1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The tools the caller may use.
|
|
241
|
+
*
|
|
242
|
+
* @returns Promise<{tools: Array, index: object}> tools in the MCP format, index by name of
|
|
243
|
+
* {service, version, tool} to make the call
|
|
244
|
+
*/
|
|
245
|
+
function listTools(req, configuration, config) {
|
|
246
|
+
let computed = aclLib.computeACL(req);
|
|
247
|
+
let finalACL = computed.finalACL || {};
|
|
248
|
+
let services = Object.keys(finalACL).filter((s) => s !== configuration.serviceName).sort();
|
|
249
|
+
return Promise.all(services.map((s) => serviceTools(req, s, finalACL[s].version, config)))
|
|
250
|
+
.then((lists) => {
|
|
251
|
+
let tools = [];
|
|
252
|
+
let index = {};
|
|
253
|
+
let taken = {};
|
|
254
|
+
services.forEach((serviceName, i) => {
|
|
255
|
+
lists[i].forEach((tool) => {
|
|
256
|
+
if (tools.length >= config.maxTools || !allowed(finalACL[serviceName], tool)) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
let name = toolName(serviceName, tool, taken);
|
|
260
|
+
let description = tool.l || (tool.m.toUpperCase() + " " + tool.v);
|
|
261
|
+
let inputSchema = (tool.inputSchema && tool.inputSchema.type === "object") ? tool.inputSchema : {"type": "object", "properties": {}};
|
|
262
|
+
tools.push({
|
|
263
|
+
"name": name,
|
|
264
|
+
"title": description,
|
|
265
|
+
"description": description + " [" + serviceName + ": " + tool.m.toUpperCase() + " " + tool.v + "]",
|
|
266
|
+
"inputSchema": inputSchema,
|
|
267
|
+
"annotations": {
|
|
268
|
+
"readOnlyHint": tool.m === "get",
|
|
269
|
+
"destructiveHint": tool.m === "delete",
|
|
270
|
+
"openWorldHint": false
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
index[name] = {"service": serviceName, "version": finalACL[serviceName].version, "tool": tool};
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
return {"tools": tools, "index": index};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let setPath = (obj, path, value) => {
|
|
281
|
+
let node = obj;
|
|
282
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
283
|
+
if (!node[path[i]] || typeof node[path[i]] !== "object") {
|
|
284
|
+
node[path[i]] = {};
|
|
285
|
+
}
|
|
286
|
+
node = node[path[i]];
|
|
287
|
+
}
|
|
288
|
+
node[path[path.length - 1]] = value;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The api call a tool call stands for: path with its params, query string and body.
|
|
293
|
+
*
|
|
294
|
+
* @returns {{path: string, query: string, body: *}|{error: string}}
|
|
295
|
+
*/
|
|
296
|
+
function buildCall(tool, args) {
|
|
297
|
+
args = (args && typeof args === "object" && !Array.isArray(args)) ? args : {};
|
|
298
|
+
let fields = tool.fields || {};
|
|
299
|
+
let params = {};
|
|
300
|
+
let query = new URLSearchParams();
|
|
301
|
+
let body;
|
|
302
|
+
Object.keys(fields).forEach((name) => {
|
|
303
|
+
if (!Object.hasOwnProperty.call(args, name) || args[name] === undefined) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
let where = fields[name] || {};
|
|
307
|
+
let path = Array.isArray(where.path) ? where.path : [name];
|
|
308
|
+
let value = args[name];
|
|
309
|
+
if (where.in === "params") {
|
|
310
|
+
params[path[0] || name] = value;
|
|
311
|
+
} else if (where.in === "query") {
|
|
312
|
+
let key = path.join(".") || name;
|
|
313
|
+
if (Array.isArray(value)) {
|
|
314
|
+
value.forEach((v) => query.append(key, (typeof v === "object") ? JSON.stringify(v) : String(v)));
|
|
315
|
+
} else {
|
|
316
|
+
query.append(key, (typeof value === "object" && value !== null) ? JSON.stringify(value) : String(value));
|
|
317
|
+
}
|
|
318
|
+
} else if (where.in === "body") {
|
|
319
|
+
if (path.length === 0) {
|
|
320
|
+
body = value;
|
|
321
|
+
} else {
|
|
322
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
323
|
+
body = {};
|
|
324
|
+
}
|
|
325
|
+
setPath(body, path, value);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
let missing = null;
|
|
330
|
+
let fill = (segment) => segment.replace(/[:*]([A-Za-z0-9_]+)/g, (all, name) => {
|
|
331
|
+
if (!Object.hasOwnProperty.call(params, name) || params[name] === null) {
|
|
332
|
+
missing = missing || name;
|
|
333
|
+
return all;
|
|
334
|
+
}
|
|
335
|
+
return encodeURIComponent(String(params[name]));
|
|
336
|
+
});
|
|
337
|
+
// optional segments: "/file{/:name}" is kept only when its params are given
|
|
338
|
+
let path = tool.v.replace(/\{([^}]*)\}/g, (all, inner) => {
|
|
339
|
+
let names = (inner.match(/[:*]([A-Za-z0-9_]+)/g) || []).map((n) => n.substring(1));
|
|
340
|
+
if (names.every((n) => Object.hasOwnProperty.call(params, n) && params[n] !== null)) {
|
|
341
|
+
return fill(inner);
|
|
342
|
+
}
|
|
343
|
+
return "";
|
|
344
|
+
});
|
|
345
|
+
path = fill(path);
|
|
346
|
+
if (missing) {
|
|
347
|
+
return {"error": "Missing path parameter: " + missing};
|
|
348
|
+
}
|
|
349
|
+
let qs = query.toString();
|
|
350
|
+
return {"path": path, "query": qs ? "?" + qs : "", "body": body};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Calls the api of a tool through the gateway's own port, so the call goes through every check an
|
|
355
|
+
* api call goes through (key, oauth, ACL, throttling, idempotency, cache), with the caller's
|
|
356
|
+
* credentials.
|
|
357
|
+
*
|
|
358
|
+
* @returns Promise<object> the MCP tool result
|
|
359
|
+
*/
|
|
360
|
+
function callTool(req, configuration, config, entry, args) {
|
|
361
|
+
let call = buildCall(entry.tool, args);
|
|
362
|
+
if (call.error) {
|
|
363
|
+
return Promise.resolve({
|
|
364
|
+
"content": [{"type": "text", "text": call.error}],
|
|
365
|
+
"isError": true
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
let port = get(["soajs", "registry", "services", configuration.serviceName, "port"], req);
|
|
369
|
+
let headers = {};
|
|
370
|
+
HEADERS_TO_FORWARD.forEach((h) => {
|
|
371
|
+
if (req.headers[h]) {
|
|
372
|
+
headers[h] = req.headers[h];
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
if (!headers.access_token && req.query && req.query.access_token) {
|
|
376
|
+
headers.access_token = req.query.access_token;
|
|
377
|
+
}
|
|
378
|
+
let clientIP = req.getClientIP ? req.getClientIP() : null;
|
|
379
|
+
let forwarded = req.headers["x-forwarded-for"];
|
|
380
|
+
if (clientIP) {
|
|
381
|
+
headers["x-forwarded-for"] = forwarded ? forwarded : clientIP;
|
|
382
|
+
}
|
|
383
|
+
let uri = "http://127.0.0.1:" + port + "/" + entry.service + "/v" + entry.version + call.path + call.query;
|
|
384
|
+
return request({
|
|
385
|
+
"uri": uri,
|
|
386
|
+
"method": entry.tool.m.toUpperCase(),
|
|
387
|
+
"headers": headers,
|
|
388
|
+
"body": (entry.tool.m === "get") ? null : (call.body === undefined ? null : call.body),
|
|
389
|
+
"timeout": config.timeout
|
|
390
|
+
}).then((response) => {
|
|
391
|
+
let json = response.json;
|
|
392
|
+
let isError = response.status >= 400 || (json && json.result === false);
|
|
393
|
+
let payload = json;
|
|
394
|
+
if (json && !isError && Object.hasOwnProperty.call(json, "data")) {
|
|
395
|
+
payload = json.data;
|
|
396
|
+
} else if (json && isError && json.errors) {
|
|
397
|
+
payload = json.errors;
|
|
398
|
+
}
|
|
399
|
+
let result = {
|
|
400
|
+
"content": [{
|
|
401
|
+
"type": "text",
|
|
402
|
+
"text": (payload === null || payload === undefined) ? response.text : JSON.stringify(payload)
|
|
403
|
+
}],
|
|
404
|
+
"isError": !!isError
|
|
405
|
+
};
|
|
406
|
+
if (!isError && payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
407
|
+
result.structuredContent = payload;
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
}).catch((err) => {
|
|
411
|
+
req.soajs.log.error("MCP: call of [" + entry.service + "@" + entry.version + " " + entry.tool.m + " " + entry.tool.v + "] failed: " + err.message);
|
|
412
|
+
return {
|
|
413
|
+
"content": [{"type": "text", "text": "The call failed: " + err.message}],
|
|
414
|
+
"isError": true
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function clearCache() {
|
|
420
|
+
cache = {};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
module.exports = {
|
|
424
|
+
getConfig,
|
|
425
|
+
request,
|
|
426
|
+
maintenancePort,
|
|
427
|
+
toolsFromAnswer,
|
|
428
|
+
serviceTools,
|
|
429
|
+
toolName,
|
|
430
|
+
allowed,
|
|
431
|
+
listTools,
|
|
432
|
+
buildCall,
|
|
433
|
+
callTool,
|
|
434
|
+
clearCache
|
|
435
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "soajs.controller",
|
|
3
3
|
"description": "soajs multi tenant API gateway",
|
|
4
|
-
"version": "4.3.
|
|
4
|
+
"version": "4.3.23",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "soajs team",
|
|
7
7
|
"email": "team@soajs.org"
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"async": "3.2.6",
|
|
22
22
|
"connect": "3.7.0",
|
|
23
23
|
"http-proxy": "1.18.1",
|
|
24
|
+
"js-yaml": "4.3.2",
|
|
24
25
|
"jsonwebtoken": "9.0.3",
|
|
25
26
|
"kubernetes-client": "9.0.0",
|
|
26
27
|
"lodash": "4.18.1",
|
|
@@ -28,18 +29,18 @@
|
|
|
28
29
|
"netmask": "2.1.1",
|
|
29
30
|
"oauth2-server": "2.4.1",
|
|
30
31
|
"path-to-regexp": "8.4.2",
|
|
31
|
-
"soajs.core.libs": "1.2.
|
|
32
|
-
"soajs.core.modules": "5.2.
|
|
33
|
-
"soajs.urac.driver": "3.2.
|
|
32
|
+
"soajs.core.libs": "1.2.3",
|
|
33
|
+
"soajs.core.modules": "5.2.22",
|
|
34
|
+
"soajs.urac.driver": "3.2.20",
|
|
34
35
|
"useragent": "2.3.0"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
|
-
"@babel/core": "7.29.
|
|
38
|
-
"@babel/plugin-transform-runtime": "7.29.
|
|
39
|
-
"@babel/preset-env": "7.29.
|
|
38
|
+
"@babel/core": "7.29.7",
|
|
39
|
+
"@babel/plugin-transform-runtime": "7.29.7",
|
|
40
|
+
"@babel/preset-env": "7.29.7",
|
|
40
41
|
"@babel/runtime": "7.28.6",
|
|
41
|
-
"express": "5.
|
|
42
|
-
"grunt": "1.6.
|
|
42
|
+
"express": "5.3.0",
|
|
43
|
+
"grunt": "1.6.3",
|
|
43
44
|
"grunt-babel": "8.0.0",
|
|
44
45
|
"grunt-contrib-clean": "2.0.1",
|
|
45
46
|
"grunt-contrib-copy": "1.0.0",
|