@tiledesk/tiledesk-server 2.23.0 → 2.24.1
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/CHANGELOG.md +9 -0
- package/Dockerfile +1 -4
- package/Dockerfile-en +1 -3
- package/Dockerfile-jobs +1 -3
- package/Dockerfile-profiler +1 -4
- package/app.js +3 -0
- package/config/native_mcp/native_mcp_servers.json +18 -0
- package/middleware/has-role.js +3 -0
- package/middleware/passport.js +1 -1
- package/package.json +1 -1
- package/pubmodules/tilebot/listener.js +3 -4
- package/pubmodules/trigger/rulesTrigger.js +4 -3
- package/routes/authtestWithRoleCheck.js +6 -0
- package/routes/faq_kb.js +4 -3
- package/routes/mcp.js +120 -30
- package/routes/webhook.js +4 -3
- package/services/mcpService.js +537 -51
- package/services/requestService.js +4 -2
- package/services/webhookService.js +4 -3
- package/test/authenticationJwt.js +120 -0
- package/test/hasRole.js +127 -0
- package/test/nativemcp.test.js +167 -0
package/services/mcpService.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
|
+
const path = require('path');
|
|
3
5
|
const axios = require('axios').default;
|
|
4
6
|
const winston = require('../config/winston');
|
|
7
|
+
const cacheUtil = require('../utils/cacheUtil');
|
|
8
|
+
const Integration = require('../models/integrations');
|
|
9
|
+
const integrationEvent = require('../event/integrationEvent');
|
|
10
|
+
|
|
11
|
+
const NATIVE_MCP_CACHE_KEY = 'native_mcp:servers';
|
|
12
|
+
const NATIVE_MCP_CONFIG_PATH = path.join(__dirname, '../config/native_mcp/native_mcp_servers.json');
|
|
5
13
|
|
|
6
14
|
|
|
7
15
|
/**
|
|
@@ -15,6 +23,167 @@ class MCPService {
|
|
|
15
23
|
// Key: `${projectId}_${url}` or just `${url}` if projectId is not provided
|
|
16
24
|
// Value: { config, sessionId?, initialized, capabilities }
|
|
17
25
|
this.connections = new Map();
|
|
26
|
+
this.tdCache = undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setTdCache(tdCache) {
|
|
30
|
+
this.tdCache = tdCache;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalizes auth.type to a canonical value
|
|
35
|
+
* Accepts: mcp_api_key, X-MCP-API-Key, x-mcp-api-key, etc.
|
|
36
|
+
* @param {String} type - Auth type from request body
|
|
37
|
+
* @returns {String} - Normalized auth type
|
|
38
|
+
*/
|
|
39
|
+
normalizeAuthType(type) {
|
|
40
|
+
return (type || '').toLowerCase().replace(/_/g, '-');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Applies custom headers from the client payload: [{ key, value }] or [{ name, value }]
|
|
45
|
+
* @param {Object} headers - HTTP headers object
|
|
46
|
+
* @param {Array} customHeaders - Custom headers array
|
|
47
|
+
* @returns {Boolean} - Whether any header was applied
|
|
48
|
+
*/
|
|
49
|
+
applyCustomHeaders(headers, customHeaders) {
|
|
50
|
+
if (!Array.isArray(customHeaders)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let applied = false;
|
|
55
|
+
for (const header of customHeaders) {
|
|
56
|
+
const name = header.key || header.name || header.headerName;
|
|
57
|
+
const value = header.value ?? header.headerValue;
|
|
58
|
+
if (name && value != null && value !== '') {
|
|
59
|
+
headers[name] = value;
|
|
60
|
+
applied = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return applied;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Applies customHeaders and/or legacy auth config to outgoing MCP requests
|
|
68
|
+
* @param {Object} headers - HTTP headers object
|
|
69
|
+
* @param {Object} serverConfig - { auth?, customHeaders? }
|
|
70
|
+
* @returns {Boolean} - Whether any header was applied
|
|
71
|
+
*/
|
|
72
|
+
applyRequestHeaders(headers, serverConfig) {
|
|
73
|
+
if (!serverConfig) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fromCustom = this.applyCustomHeaders(headers, serverConfig.customHeaders);
|
|
78
|
+
const fromAuth = serverConfig.auth ? this.applyAuthHeaders(headers, serverConfig.auth) : false;
|
|
79
|
+
return fromCustom || fromAuth;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Builds serverConfig from the client request body
|
|
84
|
+
* @param {String} projectId - Tiledesk project ID
|
|
85
|
+
* @param {Object} body - Request body
|
|
86
|
+
* @returns {Object} - Normalized server configuration
|
|
87
|
+
*/
|
|
88
|
+
buildServerConfig(projectId, body) {
|
|
89
|
+
const { url, auth, customHeaders, oauth } = body;
|
|
90
|
+
|
|
91
|
+
if (!url) {
|
|
92
|
+
throw new Error("Missing required parameter 'url'");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const hasCustomHeaders = Array.isArray(customHeaders) && customHeaders.length > 0;
|
|
96
|
+
const hasAuth = auth && typeof auth === 'object' && Object.keys(auth).length > 0;
|
|
97
|
+
const hasOAuth = oauth && typeof oauth === 'object' && (
|
|
98
|
+
oauth.clientId || oauth.clientSecret || oauth.redirectUrl || oauth.scope
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (hasOAuth && !hasCustomHeaders && !hasAuth) {
|
|
102
|
+
throw new Error('OAuth2 authentication is not supported yet. Use customHeaders instead.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (hasOAuth) {
|
|
106
|
+
winston.warn('MCP OAuth2 config received but not supported yet; ignoring oauth object');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
url,
|
|
111
|
+
projectId,
|
|
112
|
+
auth: hasAuth ? auth : undefined,
|
|
113
|
+
customHeaders: hasCustomHeaders ? customHeaders : undefined
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Applies authentication headers to an HTTP request config (legacy auth object).
|
|
119
|
+
* Supports predefined types and generic custom headers (headerName/headerValue).
|
|
120
|
+
* @param {Object} headers - HTTP headers object
|
|
121
|
+
* @param {Object} auth - Authentication configuration (optional)
|
|
122
|
+
* @returns {Boolean} - Whether auth headers were applied
|
|
123
|
+
*/
|
|
124
|
+
applyAuthHeaders(headers, auth) {
|
|
125
|
+
if (!auth) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Generic custom header — matches UI "header name" / "header value" fields
|
|
130
|
+
if (auth.headerName && auth.headerValue != null && auth.headerValue !== '') {
|
|
131
|
+
headers[auth.headerName] = auth.headerValue;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (auth.name && auth.value != null && auth.value !== '') {
|
|
135
|
+
headers[auth.name] = auth.value;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const hasCredentials = !!(auth.token || auth.key || auth.username || auth.password);
|
|
140
|
+
if (!auth.type && !hasCredentials) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const authType = this.normalizeAuthType(auth.type);
|
|
145
|
+
|
|
146
|
+
if (authType === 'custom') {
|
|
147
|
+
if (Array.isArray(auth.headers)) {
|
|
148
|
+
let applied = false;
|
|
149
|
+
for (const header of auth.headers) {
|
|
150
|
+
const name = header.key || header.name || header.headerName;
|
|
151
|
+
const value = header.value ?? header.headerValue;
|
|
152
|
+
if (name && value != null && value !== '') {
|
|
153
|
+
headers[name] = value;
|
|
154
|
+
applied = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return applied;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (authType === 'bearer') {
|
|
163
|
+
const token = auth.token || auth.key;
|
|
164
|
+
if (token) {
|
|
165
|
+
headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if ((authType === 'mcp-api-key' || authType === 'x-mcp-api-key') && auth.key) {
|
|
170
|
+
headers['X-MCP-API-Key'] = auth.key;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if ((authType === 'api-key' || authType === 'x-api-key') && auth.key) {
|
|
174
|
+
headers['X-API-Key'] = auth.key;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (authType === 'basic' && auth.username && auth.password) {
|
|
178
|
+
const credentials = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
|
|
179
|
+
headers['Authorization'] = `Basic ${credentials}`;
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (auth.type || hasCredentials) {
|
|
184
|
+
winston.warn(`MCP auth type not recognized or incomplete: "${auth.type}"`);
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
18
187
|
}
|
|
19
188
|
|
|
20
189
|
/**
|
|
@@ -37,15 +206,122 @@ class MCPService {
|
|
|
37
206
|
throw new Error('No data field found in SSE response');
|
|
38
207
|
}
|
|
39
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Parses an MCP HTTP response body (JSON, SSE, or empty)
|
|
211
|
+
* @param {*} data - Response body
|
|
212
|
+
* @returns {Object|null} - Parsed JSON object, or null for empty bodies
|
|
213
|
+
*/
|
|
214
|
+
parseResponseBody(data) {
|
|
215
|
+
if (data === '' || data == null) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
if (typeof data === 'string') {
|
|
219
|
+
if (!data.trim()) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return this.parseSSEResponse(data);
|
|
223
|
+
}
|
|
224
|
+
return data;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Sends an initialize request, retrying with an older protocol version if needed
|
|
229
|
+
* @param {String} url - MCP server URL
|
|
230
|
+
* @param {Object} auth - Authentication configuration (optional)
|
|
231
|
+
* @returns {Promise<Object>} - Initialize response
|
|
232
|
+
*/
|
|
233
|
+
async sendInitializeRequest(serverConfig) {
|
|
234
|
+
const protocolVersions = ['2025-03-26', '2024-11-05'];
|
|
235
|
+
let lastError = null;
|
|
236
|
+
|
|
237
|
+
for (const protocolVersion of protocolVersions) {
|
|
238
|
+
try {
|
|
239
|
+
return await this.sendJSONRPCRequest(serverConfig.url, {
|
|
240
|
+
jsonrpc: '2.0',
|
|
241
|
+
id: Date.now(),
|
|
242
|
+
method: 'initialize',
|
|
243
|
+
params: {
|
|
244
|
+
protocolVersion,
|
|
245
|
+
capabilities: {
|
|
246
|
+
tools: {}
|
|
247
|
+
},
|
|
248
|
+
clientInfo: {
|
|
249
|
+
name: 'tiledesk-server',
|
|
250
|
+
version: '1.0.0'
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}, serverConfig, null);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
lastError = error;
|
|
256
|
+
const isProtocolError = error.message && (
|
|
257
|
+
error.message.includes('protocol version') ||
|
|
258
|
+
error.message.includes('Unsupported protocol') ||
|
|
259
|
+
error.message.includes('code: -32602')
|
|
260
|
+
);
|
|
261
|
+
if (!isProtocolError) {
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
winston.debug(`MCP initialize failed with protocolVersion ${protocolVersion}, trying fallback`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
throw lastError;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Sends notifications/initialized when the server uses sessions.
|
|
273
|
+
* Best-effort: some servers require it (Streamable HTTP), others ignore or reject it.
|
|
274
|
+
* @returns {Promise<Boolean>} - true if notification was accepted
|
|
275
|
+
*/
|
|
276
|
+
async sendInitializedNotification(serverConfig, sessionId) {
|
|
277
|
+
const config = {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
url: serverConfig.url,
|
|
280
|
+
headers: {
|
|
281
|
+
'Content-Type': 'application/json',
|
|
282
|
+
'Accept': 'application/json, text/event-stream',
|
|
283
|
+
'mcp-session-id': sessionId
|
|
284
|
+
},
|
|
285
|
+
data: {
|
|
286
|
+
jsonrpc: '2.0',
|
|
287
|
+
method: 'notifications/initialized'
|
|
288
|
+
},
|
|
289
|
+
timeout: 30000,
|
|
290
|
+
validateStatus: function (status) {
|
|
291
|
+
return status >= 200 && status < 500;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
this.applyRequestHeaders(config.headers, serverConfig);
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const response = await axios(config);
|
|
299
|
+
|
|
300
|
+
if (response.status >= 400) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const responseData = this.parseResponseBody(response.data);
|
|
305
|
+
if (responseData?.error) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return true;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
winston.debug(`MCP notifications/initialized failed: ${error.message}`);
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
40
316
|
/**
|
|
41
317
|
* Sends a JSON-RPC request to an MCP server
|
|
42
318
|
* @param {String} url - MCP server URL
|
|
43
319
|
* @param {Object} request - JSON-RPC request
|
|
44
|
-
* @param {Object}
|
|
320
|
+
* @param {Object} serverConfig - MCP server configuration { url, projectId?, auth?, customHeaders? }
|
|
45
321
|
* @param {String} sessionId - Session ID for the MCP server (optional)
|
|
46
322
|
* @returns {Promise<Object>} - Server response (includes sessionId if present in headers)
|
|
47
323
|
*/
|
|
48
|
-
async sendJSONRPCRequest(url, request,
|
|
324
|
+
async sendJSONRPCRequest(url, request, serverConfig, sessionId) {
|
|
49
325
|
const config = {
|
|
50
326
|
method: 'POST',
|
|
51
327
|
url: url,
|
|
@@ -65,33 +341,33 @@ class MCPService {
|
|
|
65
341
|
config.headers['mcp-session-id'] = sessionId;
|
|
66
342
|
}
|
|
67
343
|
|
|
68
|
-
|
|
69
|
-
if (auth) {
|
|
70
|
-
if (auth.type === 'bearer' && auth.token) {
|
|
71
|
-
config.headers['Authorization'] = `Bearer ${auth.token}`;
|
|
72
|
-
} else if (auth.type === 'api_key' && auth.key) {
|
|
73
|
-
config.headers['X-API-Key'] = auth.key;
|
|
74
|
-
} else if (auth.type === 'basic' && auth.username && auth.password) {
|
|
75
|
-
const credentials = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
|
|
76
|
-
config.headers['Authorization'] = `Basic ${credentials}`;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
344
|
+
this.applyRequestHeaders(config.headers, serverConfig);
|
|
79
345
|
|
|
80
346
|
try {
|
|
81
347
|
const response = await axios(config);
|
|
82
348
|
|
|
349
|
+
if (response.status >= 400) {
|
|
350
|
+
const details = response.data ? ` - ${JSON.stringify(response.data)}` : '';
|
|
351
|
+
throw new Error(`HTTP Error ${response.status}: ${response.statusText}${details}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
83
354
|
// Capture session ID from response headers (if present)
|
|
84
|
-
const sessionIdFromHeader = response.headers['mcp-session-id'] ||
|
|
355
|
+
const sessionIdFromHeader = response.headers['mcp-session-id'] ||
|
|
85
356
|
response.headers['x-mcp-session-id'];
|
|
86
357
|
|
|
358
|
+
// Notifications and empty responses (e.g. 202 Accepted)
|
|
359
|
+
if (response.status === 202 || response.data === '' || response.data == null) {
|
|
360
|
+
const result = { status: response.status };
|
|
361
|
+
if (sessionIdFromHeader) {
|
|
362
|
+
result.sessionId = sessionIdFromHeader;
|
|
363
|
+
}
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
|
|
87
367
|
// Parse response: could be SSE (text/event-stream) or direct JSON
|
|
88
|
-
let responseData;
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
responseData = this.parseSSEResponse(response.data);
|
|
92
|
-
} else {
|
|
93
|
-
// JSON response already parsed
|
|
94
|
-
responseData = response.data;
|
|
368
|
+
let responseData = this.parseResponseBody(response.data);
|
|
369
|
+
if (!responseData) {
|
|
370
|
+
responseData = { status: response.status };
|
|
95
371
|
}
|
|
96
372
|
|
|
97
373
|
// Add session ID from response if present in headers
|
|
@@ -134,9 +410,226 @@ class MCPService {
|
|
|
134
410
|
return projectId ? `${projectId}_${url}` : url;
|
|
135
411
|
}
|
|
136
412
|
|
|
413
|
+
async getNativeServersCatalog() {
|
|
414
|
+
if (this.tdCache) {
|
|
415
|
+
try {
|
|
416
|
+
const cached = await this.tdCache.getJSON(NATIVE_MCP_CACHE_KEY);
|
|
417
|
+
if (cached) {
|
|
418
|
+
return cached;
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
winston.warn('Error reading native MCP servers from cache:', error);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const servers = await this.loadNativeServersFromFile();
|
|
426
|
+
|
|
427
|
+
if (this.tdCache) {
|
|
428
|
+
try {
|
|
429
|
+
await this.tdCache.setJSON(NATIVE_MCP_CACHE_KEY, servers, { EX: cacheUtil.longTTL });
|
|
430
|
+
} catch (error) {
|
|
431
|
+
winston.warn('Error caching native MCP servers:', error);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return servers;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
sanitizeNativeServerForPublic(server) {
|
|
439
|
+
const publicServer = {
|
|
440
|
+
id: server.id,
|
|
441
|
+
name: server.name,
|
|
442
|
+
native: server.native,
|
|
443
|
+
transport: server.transport
|
|
444
|
+
};
|
|
445
|
+
if (server.description) {
|
|
446
|
+
publicServer.description = server.description;
|
|
447
|
+
}
|
|
448
|
+
return publicServer;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async loadNativeServersFromFile() {
|
|
452
|
+
const content = await fs.readFile(NATIVE_MCP_CONFIG_PATH, 'utf8');
|
|
453
|
+
const servers = JSON.parse(content);
|
|
454
|
+
|
|
455
|
+
if (!Array.isArray(servers)) {
|
|
456
|
+
throw new Error('Native MCP servers configuration must be an array');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
for (const server of servers) {
|
|
460
|
+
if (!server.id) {
|
|
461
|
+
throw new Error(`Native MCP server '${server.name || 'unknown'}' is missing required field 'id'`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return servers;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async getNativeServersPublic() {
|
|
469
|
+
const servers = await this.getNativeServersCatalog();
|
|
470
|
+
return servers.map((server) => this.sanitizeNativeServerForPublic(server));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async getNativeServerById(id) {
|
|
474
|
+
const servers = await this.getNativeServersCatalog();
|
|
475
|
+
return servers.find((server) => server.id === id) || null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async resolveNativeServerConfig(nativeId, projectId) {
|
|
479
|
+
const native = await this.getNativeServerById(nativeId);
|
|
480
|
+
if (!native) {
|
|
481
|
+
const error = new Error(`Native MCP server '${nativeId}' not found`);
|
|
482
|
+
error.statusCode = 404;
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
url: native.url,
|
|
488
|
+
projectId,
|
|
489
|
+
transport: native.transport,
|
|
490
|
+
auth: native.auth || undefined
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
formatToolsForIntegration(tools) {
|
|
495
|
+
return (tools || []).map((tool) => ({
|
|
496
|
+
name: tool.name,
|
|
497
|
+
description: tool.description || ''
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
formatSelectedTools(selectedTools) {
|
|
502
|
+
return (selectedTools || []).map((tool) => ({
|
|
503
|
+
name: typeof tool === 'string' ? tool : tool.name
|
|
504
|
+
})).filter((tool) => tool.name);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
findServerIndex(servers, entry) {
|
|
508
|
+
if (entry.type === 'native') {
|
|
509
|
+
return servers.findIndex((server) => server.type === 'native' && server.nativeId === entry.nativeId);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return servers.findIndex((server) => {
|
|
513
|
+
const isCustom = server.type === 'custom' || !server.type;
|
|
514
|
+
return isCustom && server.url === entry.url;
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async normalizeMcpServerEntry(serverEntry, projectId) {
|
|
519
|
+
if (!serverEntry) {
|
|
520
|
+
throw new Error('MCP server entry is missing or invalid');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const type = serverEntry.type || (serverEntry.nativeId ? 'native' : 'custom');
|
|
524
|
+
|
|
525
|
+
if (type === 'native') {
|
|
526
|
+
if (!serverEntry.nativeId) {
|
|
527
|
+
throw new Error("Missing required field 'nativeId'");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const native = await this.getNativeServerById(serverEntry.nativeId);
|
|
531
|
+
if (!native) {
|
|
532
|
+
const error = new Error(`Native MCP server '${serverEntry.nativeId}' not found`);
|
|
533
|
+
error.statusCode = 404;
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
let tools = serverEntry.tools;
|
|
538
|
+
if (!tools || !tools.length) {
|
|
539
|
+
const serverConfig = await this.resolveNativeServerConfig(serverEntry.nativeId, projectId);
|
|
540
|
+
tools = await this.listTools(serverConfig);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
type: 'native',
|
|
545
|
+
nativeId: native.id,
|
|
546
|
+
name: native.name,
|
|
547
|
+
transport: native.transport,
|
|
548
|
+
tools: this.formatToolsForIntegration(tools),
|
|
549
|
+
selectedTools: this.formatSelectedTools(serverEntry.selectedTools)
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!serverEntry.url) {
|
|
554
|
+
throw new Error("Missing required field 'url'");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
let tools = serverEntry.tools;
|
|
558
|
+
if (!tools || !tools.length) {
|
|
559
|
+
tools = await this.listTools({
|
|
560
|
+
url: serverEntry.url,
|
|
561
|
+
projectId,
|
|
562
|
+
auth: serverEntry.auth || undefined
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
type: 'custom',
|
|
568
|
+
name: serverEntry.name || serverEntry.url,
|
|
569
|
+
url: serverEntry.url,
|
|
570
|
+
transport: serverEntry.transport || 'streamable_http',
|
|
571
|
+
customHeaders: serverEntry.customHeaders || [],
|
|
572
|
+
tools: this.formatToolsForIntegration(tools),
|
|
573
|
+
selectedTools: this.formatSelectedTools(serverEntry.selectedTools)
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async upsertMcpServerInIntegration(projectId, serverEntry) {
|
|
578
|
+
const normalized = await this.normalizeMcpServerEntry(serverEntry, projectId);
|
|
579
|
+
|
|
580
|
+
let integration = await Integration.findOne({ id_project: projectId, name: 'mcp' });
|
|
581
|
+
|
|
582
|
+
if (!integration) {
|
|
583
|
+
integration = new Integration({
|
|
584
|
+
id_project: projectId,
|
|
585
|
+
name: 'mcp',
|
|
586
|
+
value: { servers: [normalized] }
|
|
587
|
+
});
|
|
588
|
+
} else {
|
|
589
|
+
const servers = Array.isArray(integration.value?.servers) ? [...integration.value.servers] : [];
|
|
590
|
+
const index = this.findServerIndex(servers, normalized);
|
|
591
|
+
|
|
592
|
+
if (index >= 0) {
|
|
593
|
+
servers[index] = normalized;
|
|
594
|
+
} else {
|
|
595
|
+
servers.push(normalized);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
integration.value = {
|
|
599
|
+
...integration.value,
|
|
600
|
+
servers
|
|
601
|
+
};
|
|
602
|
+
integration.markModified('value');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const savedIntegration = await integration.save();
|
|
606
|
+
const integrations = await Integration.find({ id_project: projectId });
|
|
607
|
+
integrationEvent.emit('integration.update', integrations, projectId);
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
integration: savedIntegration,
|
|
611
|
+
server: normalized
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
137
615
|
/**
|
|
138
|
-
*
|
|
616
|
+
* Connects to an MCP server and returns capabilities + tools
|
|
139
617
|
* @param {Object} serverConfig - MCP server configuration { url, projectId?, auth? }
|
|
618
|
+
* @returns {Promise<Object>} - { capabilities, tools }
|
|
619
|
+
*/
|
|
620
|
+
async connectServer(serverConfig) {
|
|
621
|
+
const result = await this.initializeServer(serverConfig);
|
|
622
|
+
const tools = await this.listTools(serverConfig);
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
capabilities: result?.capabilities || {},
|
|
626
|
+
tools
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Initializes a connection to an MCP server
|
|
632
|
+
* @param {Object} serverConfig - MCP server configuration { url, projectId?, auth?, customHeaders? }
|
|
140
633
|
* @returns {Promise<Object>} - Initialization result
|
|
141
634
|
*/
|
|
142
635
|
async initializeServer(serverConfig) {
|
|
@@ -150,38 +643,30 @@ class MCPService {
|
|
|
150
643
|
// Initialize session (some MCP servers require session ID)
|
|
151
644
|
let sessionId = null;
|
|
152
645
|
let initializeResponse = null;
|
|
153
|
-
|
|
646
|
+
|
|
154
647
|
try {
|
|
155
|
-
|
|
156
|
-
const response = await this.sendJSONRPCRequest(serverConfig.url, {
|
|
157
|
-
jsonrpc: '2.0',
|
|
158
|
-
id: Date.now(),
|
|
159
|
-
method: 'initialize',
|
|
160
|
-
params: {
|
|
161
|
-
protocolVersion: '2024-11-05',
|
|
162
|
-
capabilities: {
|
|
163
|
-
tools: {}
|
|
164
|
-
},
|
|
165
|
-
clientInfo: {
|
|
166
|
-
name: 'tiledesk-server',
|
|
167
|
-
version: '1.0.0'
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}, serverConfig.auth, null);
|
|
648
|
+
const response = await this.sendInitializeRequest(serverConfig);
|
|
171
649
|
|
|
172
650
|
// Session ID is returned in 'mcp-session-id' header from response
|
|
173
651
|
sessionId = response.sessionId || null;
|
|
174
652
|
initializeResponse = response;
|
|
175
653
|
|
|
176
654
|
if (sessionId) {
|
|
177
|
-
winston.
|
|
655
|
+
winston.info(`MCP Server ${serverId} session initialized with ID: ${sessionId}`);
|
|
656
|
+
const notificationAccepted = await this.sendInitializedNotification(
|
|
657
|
+
serverConfig,
|
|
658
|
+
sessionId
|
|
659
|
+
);
|
|
660
|
+
if (notificationAccepted) {
|
|
661
|
+
winston.info(`MCP Server ${serverId} sent notifications/initialized`);
|
|
662
|
+
} else {
|
|
663
|
+
winston.warn(`MCP Server ${serverId} notifications/initialized not accepted (continuing)`);
|
|
664
|
+
}
|
|
178
665
|
} else {
|
|
179
|
-
winston.
|
|
666
|
+
winston.info(`MCP Server ${serverId} initialized (stateless, no session ID required)`);
|
|
180
667
|
}
|
|
181
668
|
} catch (initError) {
|
|
182
|
-
|
|
183
|
-
// (some MCP servers don't require session ID)
|
|
184
|
-
winston.debug(`MCP Server ${serverId} initialization failed: ${initError.message}`);
|
|
669
|
+
winston.error(`MCP Server ${serverId} initialization failed: ${initError.message}`);
|
|
185
670
|
throw initError;
|
|
186
671
|
}
|
|
187
672
|
|
|
@@ -193,7 +678,7 @@ class MCPService {
|
|
|
193
678
|
capabilities: initializeResponse.result?.capabilities || {}
|
|
194
679
|
});
|
|
195
680
|
|
|
196
|
-
winston.info(`MCP Server
|
|
681
|
+
winston.info(`MCP Server ready: ${serverId}${sessionId ? ` (session: ${sessionId})` : ' (stateless)'}`);
|
|
197
682
|
return initializeResponse.result;
|
|
198
683
|
} catch (error) {
|
|
199
684
|
winston.error(`Error initializing MCP server ${serverId}:`, error);
|
|
@@ -203,7 +688,7 @@ class MCPService {
|
|
|
203
688
|
|
|
204
689
|
/**
|
|
205
690
|
* Gets the list of available tools from an MCP server
|
|
206
|
-
* @param {Object} serverConfig - MCP server configuration { url, projectId?, auth? }
|
|
691
|
+
* @param {Object} serverConfig - MCP server configuration { url, projectId?, auth?, customHeaders? }
|
|
207
692
|
* @returns {Promise<Array>} - List of available tools
|
|
208
693
|
*/
|
|
209
694
|
async listTools(serverConfig) {
|
|
@@ -214,6 +699,8 @@ class MCPService {
|
|
|
214
699
|
const serverId = this.getCacheKey(serverConfig.url, serverConfig.projectId);
|
|
215
700
|
|
|
216
701
|
try {
|
|
702
|
+
winston.info(`MCP listTools request for ${serverId}`);
|
|
703
|
+
|
|
217
704
|
// Check if server is already initialized
|
|
218
705
|
let connection = this.connections.get(serverId);
|
|
219
706
|
if (!connection) {
|
|
@@ -232,9 +719,9 @@ class MCPService {
|
|
|
232
719
|
id: Date.now(),
|
|
233
720
|
method: 'tools/list',
|
|
234
721
|
params: {}
|
|
235
|
-
}, serverConfig
|
|
722
|
+
}, serverConfig, sessionId);
|
|
236
723
|
} catch (error) {
|
|
237
|
-
//
|
|
724
|
+
// Fallback for stateless servers that reject session IDs
|
|
238
725
|
if (sessionId && error.message && error.message.includes('No valid session ID')) {
|
|
239
726
|
winston.debug(`Retrying tools/list without session ID for server ${serverId}...`);
|
|
240
727
|
response = await this.sendJSONRPCRequest(serverConfig.url, {
|
|
@@ -242,14 +729,14 @@ class MCPService {
|
|
|
242
729
|
id: Date.now(),
|
|
243
730
|
method: 'tools/list',
|
|
244
731
|
params: {}
|
|
245
|
-
}, serverConfig
|
|
732
|
+
}, serverConfig, null);
|
|
246
733
|
} else {
|
|
247
734
|
throw error;
|
|
248
735
|
}
|
|
249
736
|
}
|
|
250
737
|
|
|
251
738
|
const tools = response.result?.tools || [];
|
|
252
|
-
winston.
|
|
739
|
+
winston.info(`MCP Server ${serverId} returned ${tools.length} tools`);
|
|
253
740
|
return tools;
|
|
254
741
|
} catch (error) {
|
|
255
742
|
winston.error(`Error listing tools from MCP server ${serverId}:`, error);
|
|
@@ -280,4 +767,3 @@ class MCPService {
|
|
|
280
767
|
const mcpService = new MCPService();
|
|
281
768
|
|
|
282
769
|
module.exports = mcpService;
|
|
283
|
-
|