@app-connect/core 1.7.23 → 1.7.24
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/handlers/admin.js +7 -4
- package/handlers/auth.js +3 -1
- package/index.js +13 -0
- package/lib/authSession.js +44 -12
- package/mcp/mcpHandler.js +31 -8
- package/mcp/tools/checkAuthStatus.js +16 -14
- package/mcp/tools/getPublicConnectors.js +11 -1
- package/mcp/tools/getSessionInfo.js +90 -0
- package/mcp/tools/index.js +3 -1
- package/mcp/tools/logout.js +10 -1
- package/models/llmSessionModel.js +3 -0
- package/package.json +1 -1
- package/releaseNotes.json +12 -0
- package/test/mcp/tools/checkAuthStatus.test.js +82 -0
- package/test/mcp/tools/getSessionInfo.test.js +127 -0
- package/test/mcp/tools/logout.test.js +58 -0
package/handlers/admin.js
CHANGED
|
@@ -6,6 +6,7 @@ const { RingCentral } = require('../lib/ringcentral');
|
|
|
6
6
|
const { Connector } = require('../models/dynamo/connectorSchema');
|
|
7
7
|
const logger = require('../lib/logger');
|
|
8
8
|
const { handleDatabaseError } = require('../lib/errorHandler');
|
|
9
|
+
const { getHashValue } = require('../lib/util');
|
|
9
10
|
|
|
10
11
|
const CALL_AGGREGATION_GROUPS = ["Company", "CompanyNumbers", "Users", "Queues", "IVRs", "IVAs", "SharedLines", "UserGroups", "Sites", "Departments"]
|
|
11
12
|
const RC_EXTENSION_ENDPOINT = 'https://platform.ringcentral.com/restapi/v1.0/account/~/extension/~';
|
|
@@ -108,7 +109,8 @@ async function getAdminReport({ rcAccountId, timezone, timeFrom, timeTo, groupBy
|
|
|
108
109
|
clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET,
|
|
109
110
|
redirectUri: `${process.env.APP_SERVER}/ringcentral/oauth/callback`
|
|
110
111
|
});
|
|
111
|
-
|
|
112
|
+
const hashedRcAccountId = getHashValue(rcAccountId, process.env.HASH_KEY);
|
|
113
|
+
let adminConfig = await AdminConfigModel.findByPk(hashedRcAccountId);
|
|
112
114
|
const isTokenExpired = adminConfig.adminTokenExpiry < new Date();
|
|
113
115
|
if (isTokenExpired) {
|
|
114
116
|
const { access_token, refresh_token, expire_time } = await rcSDK.refreshToken({
|
|
@@ -116,7 +118,7 @@ async function getAdminReport({ rcAccountId, timezone, timeFrom, timeTo, groupBy
|
|
|
116
118
|
expires_in: adminConfig.adminTokenExpiry,
|
|
117
119
|
refresh_token_expires_in: adminConfig.adminTokenExpiry
|
|
118
120
|
});
|
|
119
|
-
adminConfig = await AdminConfigModel.update({ adminAccessToken: access_token, adminRefreshToken: refresh_token, adminTokenExpiry: expire_time }, { where: { id:
|
|
121
|
+
adminConfig = await AdminConfigModel.update({ adminAccessToken: access_token, adminRefreshToken: refresh_token, adminTokenExpiry: expire_time }, { where: { id: hashedRcAccountId } });
|
|
120
122
|
}
|
|
121
123
|
const callsAggregationData = await rcSDK.getCallsAggregationData({
|
|
122
124
|
token: { access_token: adminConfig.adminAccessToken, token_type: 'Bearer' },
|
|
@@ -177,7 +179,8 @@ async function getUserReport({ rcAccountId, rcExtensionId, timezone, timeFrom, t
|
|
|
177
179
|
clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET,
|
|
178
180
|
redirectUri: `${process.env.APP_SERVER}/ringcentral/oauth/callback`
|
|
179
181
|
});
|
|
180
|
-
|
|
182
|
+
const hashedRcAccountId = getHashValue(rcAccountId, process.env.HASH_KEY);
|
|
183
|
+
let adminConfig = await AdminConfigModel.findByPk(hashedRcAccountId);
|
|
181
184
|
const isTokenExpired = adminConfig.adminTokenExpiry < new Date();
|
|
182
185
|
if (isTokenExpired) {
|
|
183
186
|
const { access_token, refresh_token, expire_time } = await rcSDK.refreshToken({
|
|
@@ -185,7 +188,7 @@ async function getUserReport({ rcAccountId, rcExtensionId, timezone, timeFrom, t
|
|
|
185
188
|
expires_in: adminConfig.adminTokenExpiry,
|
|
186
189
|
refresh_token_expires_in: adminConfig.adminTokenExpiry
|
|
187
190
|
});
|
|
188
|
-
adminConfig = await AdminConfigModel.update({ adminAccessToken: access_token, adminRefreshToken: refresh_token, adminTokenExpiry: expire_time }, { where: { id:
|
|
191
|
+
adminConfig = await AdminConfigModel.update({ adminAccessToken: access_token, adminRefreshToken: refresh_token, adminTokenExpiry: expire_time }, { where: { id: hashedRcAccountId } });
|
|
189
192
|
}
|
|
190
193
|
const callLogData = await rcSDK.getCallLogData({
|
|
191
194
|
extensionId: rcExtensionId,
|
package/handlers/auth.js
CHANGED
|
@@ -7,6 +7,7 @@ const adminCore = require('./admin');
|
|
|
7
7
|
const { Connector } = require('../models/dynamo/connectorSchema');
|
|
8
8
|
const { handleDatabaseError } = require('../lib/errorHandler');
|
|
9
9
|
const managedAuthCore = require('./managedAuth');
|
|
10
|
+
const { getHashValue } = require('../lib/util');
|
|
10
11
|
|
|
11
12
|
async function onOAuthCallback({ platform, hostname, tokenUrl, query, hashedRcExtensionId, isFromMCP = false }) {
|
|
12
13
|
const callbackUri = query.callbackUri;
|
|
@@ -279,8 +280,9 @@ async function onRingcentralOAuthCallback({ code, rcAccountId }) {
|
|
|
279
280
|
redirectUri: `${process.env.APP_SERVER}/ringcentral/oauth/callback`
|
|
280
281
|
});
|
|
281
282
|
const { access_token, refresh_token, expire_time } = await rcSDK.generateToken({ code });
|
|
283
|
+
const hashedRcAccountId = getHashValue(rcAccountId, process.env.HASH_KEY);
|
|
282
284
|
await adminCore.updateAdminRcTokens({
|
|
283
|
-
hashedRcAccountId
|
|
285
|
+
hashedRcAccountId,
|
|
284
286
|
adminAccessToken: access_token,
|
|
285
287
|
adminRefreshToken: refresh_token,
|
|
286
288
|
adminTokenExpiry: expire_time
|
package/index.js
CHANGED
|
@@ -89,6 +89,19 @@ async function initDB() {
|
|
|
89
89
|
await UserModel.sync();
|
|
90
90
|
logger.info('hashedRcExtensionId column added to users table');
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
// if LlmSessionModel doesn't have expiry column, add it
|
|
94
|
+
const llmSessionTableName = LlmSessionModel.getTableName();
|
|
95
|
+
const llmSessionTableSchema = await queryInterface.describeTable(llmSessionTableName);
|
|
96
|
+
if (!llmSessionTableSchema.expiry) {
|
|
97
|
+
logger.info('adding expiry column to llmSessions table...');
|
|
98
|
+
await queryInterface.addColumn(llmSessionTableName, 'expiry', {
|
|
99
|
+
type: Sequelize.DATE,
|
|
100
|
+
allowNull: true,
|
|
101
|
+
});
|
|
102
|
+
await LlmSessionModel.sync();
|
|
103
|
+
logger.info('expiry column added to llmSessions table');
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
}
|
|
94
107
|
|
package/lib/authSession.js
CHANGED
|
@@ -7,7 +7,20 @@
|
|
|
7
7
|
const { CacheModel } = require('../models/cacheModel');
|
|
8
8
|
|
|
9
9
|
const AUTH_SESSION_PREFIX = 'auth-session';
|
|
10
|
-
const
|
|
10
|
+
const PENDING_SESSION_EXPIRY_MINUTES = 5;
|
|
11
|
+
const SETTLED_SESSION_EXPIRY_MINUTES = 15;
|
|
12
|
+
|
|
13
|
+
function getSessionRecordId(sessionId) {
|
|
14
|
+
return `${AUTH_SESSION_PREFIX}-${sessionId}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getExpiry(minutes) {
|
|
18
|
+
return new Date(Date.now() + minutes * 60 * 1000);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isExpired(record) {
|
|
22
|
+
return Boolean(record?.expiry && record.expiry <= new Date());
|
|
23
|
+
}
|
|
11
24
|
|
|
12
25
|
/**
|
|
13
26
|
* Create (or reset) an auth session.
|
|
@@ -16,9 +29,13 @@ const SESSION_EXPIRY_MINUTES = 5;
|
|
|
16
29
|
* correctly for the new attempt.
|
|
17
30
|
*/
|
|
18
31
|
async function createAuthSession(sessionId, data) {
|
|
19
|
-
const id =
|
|
20
|
-
const expiry =
|
|
21
|
-
const sessionData = {
|
|
32
|
+
const id = getSessionRecordId(sessionId);
|
|
33
|
+
const expiry = getExpiry(PENDING_SESSION_EXPIRY_MINUTES);
|
|
34
|
+
const sessionData = {
|
|
35
|
+
platform: data.platform,
|
|
36
|
+
hostname: data.hostname || '',
|
|
37
|
+
createdAt: new Date().toISOString(),
|
|
38
|
+
};
|
|
22
39
|
|
|
23
40
|
const existing = await CacheModel.findByPk(id);
|
|
24
41
|
if (existing) {
|
|
@@ -39,10 +56,21 @@ async function createAuthSession(sessionId, data) {
|
|
|
39
56
|
* Get an auth session by ID
|
|
40
57
|
*/
|
|
41
58
|
async function getAuthSession(sessionId) {
|
|
42
|
-
const record = await CacheModel.findByPk(
|
|
43
|
-
|
|
59
|
+
const record = await CacheModel.findByPk(getSessionRecordId(sessionId));
|
|
60
|
+
|
|
44
61
|
if (!record) return null;
|
|
45
|
-
|
|
62
|
+
|
|
63
|
+
if (isExpired(record)) {
|
|
64
|
+
if (record.status !== 'expired') {
|
|
65
|
+
await record.update({ status: 'expired' });
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
sessionId: record.userId,
|
|
69
|
+
status: 'expired',
|
|
70
|
+
...record.data
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
46
74
|
return {
|
|
47
75
|
sessionId: record.userId,
|
|
48
76
|
status: record.status,
|
|
@@ -54,18 +82,22 @@ async function getAuthSession(sessionId) {
|
|
|
54
82
|
* Update an auth session
|
|
55
83
|
*/
|
|
56
84
|
async function updateAuthSession(sessionId, data) {
|
|
57
|
-
const record = await CacheModel.findByPk(
|
|
58
|
-
|
|
85
|
+
const record = await CacheModel.findByPk(getSessionRecordId(sessionId));
|
|
86
|
+
|
|
59
87
|
if (!record) return;
|
|
60
|
-
|
|
88
|
+
|
|
61
89
|
const existingData = record.data || {};
|
|
90
|
+
const nextStatus = data.status || record.status;
|
|
62
91
|
await record.update({
|
|
63
|
-
status:
|
|
92
|
+
status: nextStatus,
|
|
64
93
|
data: {
|
|
65
94
|
...existingData,
|
|
66
95
|
...data,
|
|
67
96
|
updatedAt: new Date().toISOString()
|
|
68
|
-
}
|
|
97
|
+
},
|
|
98
|
+
expiry: nextStatus === 'pending'
|
|
99
|
+
? getExpiry(PENDING_SESSION_EXPIRY_MINUTES)
|
|
100
|
+
: getExpiry(SETTLED_SESSION_EXPIRY_MINUTES)
|
|
69
101
|
});
|
|
70
102
|
}
|
|
71
103
|
|
package/mcp/mcpHandler.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const axios = require('axios');
|
|
10
|
+
const { Op } = require('sequelize');
|
|
10
11
|
const tools = require('./tools');
|
|
11
12
|
const { LlmSessionModel } = require('../models/llmSessionModel');
|
|
12
13
|
const { CacheModel } = require('../models/cacheModel');
|
|
@@ -23,6 +24,8 @@ const path = require('path');
|
|
|
23
24
|
*/
|
|
24
25
|
const WIDGET_VERSION = 10;
|
|
25
26
|
const WIDGET_URI = `ui://widget/ConnectorList-v${WIDGET_VERSION}.html`;
|
|
27
|
+
const RC_EXTENSION_CACHE_KEY = 'rcExtensionId';
|
|
28
|
+
const RC_EXTENSION_CACHE_STATUS = 'resolved';
|
|
26
29
|
|
|
27
30
|
const JSON_RPC_INTERNAL_ERROR = -32603;
|
|
28
31
|
const JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
@@ -104,7 +107,7 @@ async function resolveSessionContext(rcAccessToken, openaiSessionId) {
|
|
|
104
107
|
|
|
105
108
|
if (openaiSessionId) {
|
|
106
109
|
try {
|
|
107
|
-
const cached = await CacheModel.findByPk(`${openaiSessionId}
|
|
110
|
+
const cached = await CacheModel.findByPk(`${openaiSessionId}-${RC_EXTENSION_CACHE_KEY}`);
|
|
108
111
|
if (cached?.data?.rcExtensionId && (!cached.expiry || cached.expiry > new Date())) {
|
|
109
112
|
return { rcExtensionId: cached.data.rcExtensionId };
|
|
110
113
|
}
|
|
@@ -118,11 +121,11 @@ async function resolveSessionContext(rcAccessToken, openaiSessionId) {
|
|
|
118
121
|
rcExtensionId = await resolveRcExtensionId(rcAccessToken);
|
|
119
122
|
if (openaiSessionId && rcExtensionId) {
|
|
120
123
|
await CacheModel.upsert({
|
|
121
|
-
id: `${openaiSessionId}
|
|
124
|
+
id: `${openaiSessionId}-${RC_EXTENSION_CACHE_KEY}`,
|
|
122
125
|
userId: openaiSessionId,
|
|
123
|
-
cacheKey:
|
|
126
|
+
cacheKey: RC_EXTENSION_CACHE_KEY,
|
|
124
127
|
data: { rcExtensionId },
|
|
125
|
-
status:
|
|
128
|
+
status: RC_EXTENSION_CACHE_STATUS,
|
|
126
129
|
expiry: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h TTL
|
|
127
130
|
});
|
|
128
131
|
}
|
|
@@ -203,15 +206,34 @@ async function handleMcpRequest(req, res) {
|
|
|
203
206
|
toolArgs.rcExtensionId = rcExtensionId;
|
|
204
207
|
if (!toolArgs.jwtToken) {
|
|
205
208
|
let llmSession = await LlmSessionModel.findByPk(rcExtensionId);
|
|
209
|
+
if (llmSession?.expiry && llmSession.expiry < new Date()) {
|
|
210
|
+
await LlmSessionModel.destroy({ where: { id: rcExtensionId } });
|
|
211
|
+
llmSession = null;
|
|
212
|
+
}
|
|
206
213
|
if (!llmSession?.jwtToken && openaiSessionId) {
|
|
207
214
|
const fallback = await LlmSessionModel.findByPk(openaiSessionId);
|
|
208
215
|
if (fallback?.jwtToken) {
|
|
209
|
-
|
|
210
|
-
|
|
216
|
+
const { id: fallbackUserId } = jwt.decodeJwt(fallback.jwtToken);
|
|
217
|
+
const fallbackUser = fallbackUserId
|
|
218
|
+
? await UserModel.findByPk(fallbackUserId)
|
|
219
|
+
: null;
|
|
220
|
+
if (fallbackUser?.accessToken) {
|
|
221
|
+
await LlmSessionModel.upsert({ id: rcExtensionId, jwtToken: fallback.jwtToken, expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) });
|
|
222
|
+
llmSession = fallback;
|
|
223
|
+
}
|
|
211
224
|
}
|
|
212
|
-
|
|
225
|
+
if (!llmSession?.jwtToken) {
|
|
213
226
|
const hashedRcExtensionId = getHashValue(rcExtensionId, process.env.HASH_KEY);
|
|
214
|
-
const user = await UserModel.findOne({
|
|
227
|
+
const user = await UserModel.findOne({
|
|
228
|
+
where: {
|
|
229
|
+
hashedRcExtensionId,
|
|
230
|
+
[Op.and]: [
|
|
231
|
+
{ accessToken: { [Op.not]: null } },
|
|
232
|
+
{ accessToken: { [Op.ne]: '' } },
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
order: [['updatedAt', 'DESC']],
|
|
236
|
+
});
|
|
215
237
|
if (user?.accessToken) {
|
|
216
238
|
await LlmSessionModel.upsert({
|
|
217
239
|
id: rcExtensionId,
|
|
@@ -219,6 +241,7 @@ async function handleMcpRequest(req, res) {
|
|
|
219
241
|
id: user.id.toString(),
|
|
220
242
|
platform: user.platform
|
|
221
243
|
}),
|
|
244
|
+
expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
|
222
245
|
});
|
|
223
246
|
llmSession = await LlmSessionModel.findByPk(rcExtensionId);
|
|
224
247
|
}
|
|
@@ -3,7 +3,7 @@ const { LlmSessionModel } = require('../../models/llmSessionModel');
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* MCP Tool: Check Auth Status
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
7
|
* Polls the status of an ongoing OAuth authentication session
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -37,7 +37,7 @@ const toolDefinition = {
|
|
|
37
37
|
async function execute(args) {
|
|
38
38
|
try {
|
|
39
39
|
// rcExtensionId is injected by mcpHandler after verifying the RC access
|
|
40
|
-
// token.
|
|
40
|
+
// token. Using it as the DB key binds the CRM credential to a verified
|
|
41
41
|
// RC identity.
|
|
42
42
|
const { sessionId, rcExtensionId } = args;
|
|
43
43
|
if (!rcExtensionId) {
|
|
@@ -53,16 +53,12 @@ async function execute(args) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
switch (session.status) {
|
|
56
|
-
case 'completed':
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
});
|
|
63
|
-
} catch {
|
|
64
|
-
// Record already exists from a prior poll — safe to ignore
|
|
65
|
-
}
|
|
56
|
+
case 'completed':
|
|
57
|
+
await LlmSessionModel.upsert({
|
|
58
|
+
id: rcExtensionId,
|
|
59
|
+
jwtToken: session.jwtToken,
|
|
60
|
+
expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
|
61
|
+
});
|
|
66
62
|
return {
|
|
67
63
|
data: {
|
|
68
64
|
status: 'completed',
|
|
@@ -71,7 +67,14 @@ async function execute(args) {
|
|
|
71
67
|
message: 'IMPORTANT: Authentication successful! Keep jwtToken in memory for future use. DO NOT directly show it to user.'
|
|
72
68
|
}
|
|
73
69
|
};
|
|
74
|
-
|
|
70
|
+
|
|
71
|
+
case 'expired':
|
|
72
|
+
return {
|
|
73
|
+
data: {
|
|
74
|
+
status: 'expired',
|
|
75
|
+
errorMessage: 'Authentication session expired. Ask the user to start the auth flow again.'
|
|
76
|
+
}
|
|
77
|
+
};
|
|
75
78
|
|
|
76
79
|
case 'failed':
|
|
77
80
|
return {
|
|
@@ -100,4 +103,3 @@ async function execute(args) {
|
|
|
100
103
|
|
|
101
104
|
exports.definition = toolDefinition;
|
|
102
105
|
exports.execute = execute;
|
|
103
|
-
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
|
+
const { Op } = require('sequelize');
|
|
2
3
|
const { UserModel } = require('../../models/userModel');
|
|
3
4
|
const { getHashValue } = require('../../lib/util');
|
|
4
5
|
|
|
@@ -56,7 +57,16 @@ async function execute({ rcAccessToken, openaiSessionId } = {}) {
|
|
|
56
57
|
|
|
57
58
|
// Check if user session already exists from Chrome extension
|
|
58
59
|
const hashedRcExtensionId = getHashValue(rcExtensionId, process.env.HASH_KEY);
|
|
59
|
-
const user = await UserModel.findOne({
|
|
60
|
+
const user = await UserModel.findOne({
|
|
61
|
+
where: {
|
|
62
|
+
hashedRcExtensionId,
|
|
63
|
+
[Op.and]: [
|
|
64
|
+
{ accessToken: { [Op.not]: null } },
|
|
65
|
+
{ accessToken: { [Op.ne]: '' } },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
order: [['updatedAt', 'DESC']],
|
|
69
|
+
});
|
|
60
70
|
// Case: user exists, return user info in plain message
|
|
61
71
|
if (user?.accessToken) {
|
|
62
72
|
return {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const jwt = require('../../lib/jwt');
|
|
2
|
+
const { UserModel } = require('../../models/userModel');
|
|
3
|
+
const { RingCentral } = require('../../lib/ringcentral');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MCP Tool: Get Session Info
|
|
7
|
+
*
|
|
8
|
+
* Returns non-sensitive information about the current MCP/CRM session.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const toolDefinition = {
|
|
12
|
+
name: 'getSessionInfo',
|
|
13
|
+
description: 'Get the current user session info, including RingCentral identity and CRM connection status.',
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {},
|
|
17
|
+
required: []
|
|
18
|
+
},
|
|
19
|
+
annotations: {
|
|
20
|
+
readOnlyHint: true,
|
|
21
|
+
openWorldHint: false,
|
|
22
|
+
destructiveHint: false
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Execute the getSessionInfo tool
|
|
28
|
+
* @param {Object} args - Tool arguments injected by mcpHandler
|
|
29
|
+
* @param {string} [args.openaiSessionId] - OpenAI session identifier
|
|
30
|
+
* @param {string} [args.rcExtensionId] - Verified RingCentral extension identifier
|
|
31
|
+
* @param {string} [args.jwtToken] - CRM JWT token
|
|
32
|
+
* @param {string} [args.rcAccessToken] - RingCentral access token
|
|
33
|
+
* @returns {Object} Result object with session information
|
|
34
|
+
*/
|
|
35
|
+
async function execute(args = {}) {
|
|
36
|
+
try {
|
|
37
|
+
const {
|
|
38
|
+
openaiSessionId = null,
|
|
39
|
+
rcExtensionId = null,
|
|
40
|
+
jwtToken,
|
|
41
|
+
rcAccessToken,
|
|
42
|
+
} = args;
|
|
43
|
+
|
|
44
|
+
const decodedToken = jwtToken ? jwt.decodeJwt(jwtToken) : null;
|
|
45
|
+
const userId = decodedToken?.id ?? null;
|
|
46
|
+
const user = userId ? await UserModel.findByPk(userId) : null;
|
|
47
|
+
|
|
48
|
+
let rcExtensionInfo = null;
|
|
49
|
+
if (rcExtensionId && rcAccessToken) {
|
|
50
|
+
const rcSDK = new RingCentral({
|
|
51
|
+
server: process.env.RINGCENTRAL_SERVER,
|
|
52
|
+
clientId: process.env.RINGCENTRAL_CLIENT_ID,
|
|
53
|
+
clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET,
|
|
54
|
+
redirectUri: `${process.env.APP_SERVER}/ringcentral/oauth/callback`
|
|
55
|
+
});
|
|
56
|
+
rcExtensionInfo = await rcSDK.getExtensionInfo(rcExtensionId, {
|
|
57
|
+
access_token: rcAccessToken,
|
|
58
|
+
token_type: 'Bearer'
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
success: true,
|
|
63
|
+
data: {
|
|
64
|
+
openaiSessionId,
|
|
65
|
+
dataToShow: {
|
|
66
|
+
isCrmAuthenticated: Boolean(decodedToken && user?.accessToken),
|
|
67
|
+
ringcentral: {
|
|
68
|
+
extensionId: rcExtensionId ?? null,
|
|
69
|
+
name: rcExtensionInfo?.name ?? null,
|
|
70
|
+
},
|
|
71
|
+
crm: {
|
|
72
|
+
userId,
|
|
73
|
+
platform: decodedToken?.platform ?? user?.platform ?? null,
|
|
74
|
+
hostname: user?.hostname ?? null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
return {
|
|
82
|
+
success: false,
|
|
83
|
+
error: error.message || 'Unknown error occurred',
|
|
84
|
+
errorDetails: error.stack
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
exports.definition = toolDefinition;
|
|
90
|
+
exports.execute = execute;
|
package/mcp/tools/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const getHelp = require('./getHelp');
|
|
10
10
|
const getPublicConnectors = require('./getPublicConnectors');
|
|
11
|
+
const getSessionInfo = require('./getSessionInfo');
|
|
11
12
|
const doAuth = require('./doAuth');
|
|
12
13
|
const checkAuthStatus = require('./checkAuthStatus');
|
|
13
14
|
const logout = require('./logout');
|
|
@@ -15,13 +16,14 @@ const findContact = require('./findContactByPhone');
|
|
|
15
16
|
const findContactWithName = require('./findContactByName');
|
|
16
17
|
const createCallLog = require('./createCallLog');
|
|
17
18
|
const rcGetCallLogs = require('./rcGetCallLogs');
|
|
18
|
-
const getGoogleFilePicker = require('./getGoogleFilePicker');
|
|
19
|
+
// const getGoogleFilePicker = require('./getGoogleFilePicker');
|
|
19
20
|
const createContact = require('./createContact');
|
|
20
21
|
|
|
21
22
|
// AI-visible MCP tools — registered in the MCP server
|
|
22
23
|
module.exports.tools = [
|
|
23
24
|
getHelp,
|
|
24
25
|
getPublicConnectors,
|
|
26
|
+
getSessionInfo,
|
|
25
27
|
logout,
|
|
26
28
|
findContact,
|
|
27
29
|
findContactWithName,
|
package/mcp/tools/logout.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
const jwt = require('../../lib/jwt');
|
|
2
2
|
const { UserModel } = require('../../models/userModel');
|
|
3
3
|
const { LlmSessionModel } = require('../../models/llmSessionModel');
|
|
4
|
+
const { CacheModel } = require('../../models/cacheModel');
|
|
4
5
|
const connectorRegistry = require('../../connector/registry');
|
|
5
6
|
|
|
7
|
+
const RC_EXTENSION_CACHE_KEY = 'rcExtensionId';
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* MCP Tool: Logout
|
|
8
11
|
*
|
|
@@ -38,7 +41,7 @@ function isMissingSessionTableError(error) {
|
|
|
38
41
|
*/
|
|
39
42
|
async function execute(args) {
|
|
40
43
|
try {
|
|
41
|
-
const { jwtToken } = args;
|
|
44
|
+
const { jwtToken, rcExtensionId, openaiSessionId } = args;
|
|
42
45
|
const session = jwt.decodeJwt(jwtToken);
|
|
43
46
|
if (!session?.platform || !session?.id) {
|
|
44
47
|
throw new Error('Invalid JWT token');
|
|
@@ -46,6 +49,12 @@ async function execute(args) {
|
|
|
46
49
|
const { platform, id } = session;
|
|
47
50
|
try {
|
|
48
51
|
await LlmSessionModel.destroy({ where: { id } });
|
|
52
|
+
if (rcExtensionId && rcExtensionId !== id) {
|
|
53
|
+
await LlmSessionModel.destroy({ where: { id: rcExtensionId } });
|
|
54
|
+
}
|
|
55
|
+
if (openaiSessionId) {
|
|
56
|
+
await CacheModel.destroy({ where: { id: `${openaiSessionId}-${RC_EXTENSION_CACHE_KEY}` } });
|
|
57
|
+
}
|
|
49
58
|
}
|
|
50
59
|
catch (error) {
|
|
51
60
|
if (!isMissingSessionTableError(error)) {
|
package/package.json
CHANGED
package/releaseNotes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
+
"1.7.24": {
|
|
3
|
+
"global": [
|
|
4
|
+
{
|
|
5
|
+
"type": "New",
|
|
6
|
+
"description": "Click-to-dial number matcher selection in User Setting -> General"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"type": "Fix",
|
|
10
|
+
"description": "Error report upload"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
},
|
|
2
14
|
"1.7.23": {
|
|
3
15
|
"global": [
|
|
4
16
|
{
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const checkAuthStatus = require('../../../mcp/tools/checkAuthStatus');
|
|
2
|
+
const { getAuthSession } = require('../../../lib/authSession');
|
|
3
|
+
const { LlmSessionModel } = require('../../../models/llmSessionModel');
|
|
4
|
+
|
|
5
|
+
jest.mock('../../../lib/authSession');
|
|
6
|
+
jest.mock('../../../models/llmSessionModel');
|
|
7
|
+
|
|
8
|
+
describe('MCP Tool: checkAuthStatus', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
jest.clearAllMocks();
|
|
11
|
+
LlmSessionModel.upsert.mockResolvedValue([{}, true]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('should return pending when auth session is still waiting', async () => {
|
|
15
|
+
getAuthSession.mockResolvedValue({ status: 'pending' });
|
|
16
|
+
|
|
17
|
+
const result = await checkAuthStatus.execute({
|
|
18
|
+
sessionId: 'session-1',
|
|
19
|
+
rcExtensionId: 'rc-ext-1'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(result).toEqual({
|
|
23
|
+
data: {
|
|
24
|
+
status: 'pending'
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
expect(LlmSessionModel.upsert).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('should persist completed auth against rcExtensionId', async () => {
|
|
31
|
+
getAuthSession.mockResolvedValue({
|
|
32
|
+
status: 'completed',
|
|
33
|
+
jwtToken: 'jwt-token',
|
|
34
|
+
userInfo: { id: 'user-1', name: 'Casey' }
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const result = await checkAuthStatus.execute({
|
|
38
|
+
sessionId: 'session-1',
|
|
39
|
+
rcExtensionId: 'rc-ext-1'
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(LlmSessionModel.upsert).toHaveBeenCalledWith({
|
|
43
|
+
id: 'rc-ext-1',
|
|
44
|
+
jwtToken: 'jwt-token'
|
|
45
|
+
});
|
|
46
|
+
expect(result).toEqual({
|
|
47
|
+
data: {
|
|
48
|
+
status: 'completed',
|
|
49
|
+
jwtToken: 'jwt-token',
|
|
50
|
+
userInfo: { id: 'user-1', name: 'Casey' },
|
|
51
|
+
message: expect.stringContaining('IMPORTANT')
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('should return expired when the auth session TTL has elapsed', async () => {
|
|
57
|
+
getAuthSession.mockResolvedValue({ status: 'expired' });
|
|
58
|
+
|
|
59
|
+
const result = await checkAuthStatus.execute({
|
|
60
|
+
sessionId: 'session-1',
|
|
61
|
+
rcExtensionId: 'rc-ext-1'
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result).toEqual({
|
|
65
|
+
data: {
|
|
66
|
+
status: 'expired',
|
|
67
|
+
errorMessage: 'Authentication session expired. Ask the user to start the auth flow again.'
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('should return an error when rcExtensionId is missing', async () => {
|
|
73
|
+
const result = await checkAuthStatus.execute({
|
|
74
|
+
sessionId: 'session-1'
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(result).toEqual({
|
|
78
|
+
success: false,
|
|
79
|
+
error: 'CRM auth status check error: rcExtensionId is required'
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const getSessionInfo = require('../../../mcp/tools/getSessionInfo');
|
|
2
|
+
const jwt = require('../../../lib/jwt');
|
|
3
|
+
const { UserModel } = require('../../../models/userModel');
|
|
4
|
+
const { RingCentral } = require('../../../lib/ringcentral');
|
|
5
|
+
|
|
6
|
+
jest.mock('../../../lib/jwt');
|
|
7
|
+
jest.mock('../../../models/userModel');
|
|
8
|
+
jest.mock('../../../lib/ringcentral');
|
|
9
|
+
|
|
10
|
+
describe('MCP Tool: getSessionInfo', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
jest.clearAllMocks();
|
|
13
|
+
RingCentral.mockImplementation(() => ({
|
|
14
|
+
getExtensionInfo: jest.fn().mockResolvedValue({ name: 'Demo Extension' })
|
|
15
|
+
}));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('tool definition', () => {
|
|
19
|
+
test('should have correct tool definition', () => {
|
|
20
|
+
expect(getSessionInfo.definition).toBeDefined();
|
|
21
|
+
expect(getSessionInfo.definition.name).toBe('getSessionInfo');
|
|
22
|
+
expect(getSessionInfo.definition.description).toContain('session info');
|
|
23
|
+
expect(getSessionInfo.definition.inputSchema).toBeDefined();
|
|
24
|
+
expect(getSessionInfo.definition.inputSchema.properties).toEqual({});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('execute', () => {
|
|
29
|
+
test('should return unauthenticated session info when no CRM jwtToken exists', async () => {
|
|
30
|
+
const result = await getSessionInfo.execute({
|
|
31
|
+
openaiSessionId: 'session-123',
|
|
32
|
+
rcExtensionId: 'ext-456',
|
|
33
|
+
rcAccessToken: 'rc-token'
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result).toEqual({
|
|
37
|
+
success: true,
|
|
38
|
+
data: {
|
|
39
|
+
openaiSessionId: 'session-123',
|
|
40
|
+
dataToShow: {
|
|
41
|
+
isCrmAuthenticated: false,
|
|
42
|
+
ringcentral: {
|
|
43
|
+
extensionId: 'ext-456',
|
|
44
|
+
name: 'Demo Extension',
|
|
45
|
+
},
|
|
46
|
+
crm: {
|
|
47
|
+
userId: null,
|
|
48
|
+
platform: null,
|
|
49
|
+
hostname: null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
expect(jwt.decodeJwt).not.toHaveBeenCalled();
|
|
55
|
+
expect(UserModel.findByPk).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('should return connected CRM session info when jwtToken resolves to a saved user', async () => {
|
|
59
|
+
jwt.decodeJwt.mockReturnValue({
|
|
60
|
+
id: 'crm-user-1',
|
|
61
|
+
platform: 'clio'
|
|
62
|
+
});
|
|
63
|
+
UserModel.findByPk.mockResolvedValue({
|
|
64
|
+
id: 'crm-user-1',
|
|
65
|
+
platform: 'clio',
|
|
66
|
+
hostname: 'app.clio.com',
|
|
67
|
+
accessToken: 'crm-access-token',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const result = await getSessionInfo.execute({
|
|
71
|
+
openaiSessionId: 'session-123',
|
|
72
|
+
rcExtensionId: 'ext-456',
|
|
73
|
+
rcAccessToken: 'rc-token',
|
|
74
|
+
jwtToken: 'jwt-token'
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(jwt.decodeJwt).toHaveBeenCalledWith('jwt-token');
|
|
78
|
+
expect(UserModel.findByPk).toHaveBeenCalledWith('crm-user-1');
|
|
79
|
+
expect(result).toEqual({
|
|
80
|
+
success: true,
|
|
81
|
+
data: {
|
|
82
|
+
openaiSessionId: 'session-123',
|
|
83
|
+
dataToShow: {
|
|
84
|
+
isCrmAuthenticated: true,
|
|
85
|
+
ringcentral: {
|
|
86
|
+
extensionId: 'ext-456',
|
|
87
|
+
name: 'Demo Extension',
|
|
88
|
+
},
|
|
89
|
+
crm: {
|
|
90
|
+
userId: 'crm-user-1',
|
|
91
|
+
platform: 'clio',
|
|
92
|
+
hostname: 'app.clio.com'
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('should report not authenticated when jwtToken is invalid', async () => {
|
|
100
|
+
jwt.decodeJwt.mockReturnValue(null);
|
|
101
|
+
|
|
102
|
+
const result = await getSessionInfo.execute({
|
|
103
|
+
jwtToken: 'bad-token'
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result).toEqual({
|
|
107
|
+
success: true,
|
|
108
|
+
data: {
|
|
109
|
+
openaiSessionId: null,
|
|
110
|
+
dataToShow: {
|
|
111
|
+
isCrmAuthenticated: false,
|
|
112
|
+
ringcentral: {
|
|
113
|
+
extensionId: null,
|
|
114
|
+
name: null,
|
|
115
|
+
},
|
|
116
|
+
crm: {
|
|
117
|
+
userId: null,
|
|
118
|
+
platform: null,
|
|
119
|
+
hostname: null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
expect(UserModel.findByPk).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
const logout = require('../../../mcp/tools/logout');
|
|
2
2
|
const jwt = require('../../../lib/jwt');
|
|
3
3
|
const { UserModel } = require('../../../models/userModel');
|
|
4
|
+
const { LlmSessionModel } = require('../../../models/llmSessionModel');
|
|
5
|
+
const { CacheModel } = require('../../../models/cacheModel');
|
|
4
6
|
const connectorRegistry = require('../../../connector/registry');
|
|
5
7
|
|
|
6
8
|
// Mock dependencies
|
|
7
9
|
jest.mock('../../../lib/jwt');
|
|
8
10
|
jest.mock('../../../models/userModel');
|
|
11
|
+
jest.mock('../../../models/llmSessionModel');
|
|
12
|
+
jest.mock('../../../models/cacheModel');
|
|
9
13
|
jest.mock('../../../connector/registry');
|
|
10
14
|
|
|
11
15
|
describe('MCP Tool: logout', () => {
|
|
12
16
|
beforeEach(() => {
|
|
13
17
|
jest.clearAllMocks();
|
|
18
|
+
LlmSessionModel.destroy.mockResolvedValue(1);
|
|
19
|
+
CacheModel.destroy.mockResolvedValue(1);
|
|
14
20
|
});
|
|
15
21
|
|
|
16
22
|
describe('tool definition', () => {
|
|
@@ -65,6 +71,7 @@ describe('MCP Tool: logout', () => {
|
|
|
65
71
|
}
|
|
66
72
|
});
|
|
67
73
|
expect(jwt.decodeJwt).toHaveBeenCalledWith('mock-jwt-token');
|
|
74
|
+
expect(LlmSessionModel.destroy).toHaveBeenCalledWith({ where: { id: 'test-user-id' } });
|
|
68
75
|
expect(UserModel.findByPk).toHaveBeenCalledWith('test-user-id');
|
|
69
76
|
expect(connectorRegistry.getConnector).toHaveBeenCalledWith('testCRM');
|
|
70
77
|
expect(mockConnector.unAuthorize).toHaveBeenCalledWith({
|
|
@@ -170,6 +177,57 @@ describe('MCP Tool: logout', () => {
|
|
|
170
177
|
expect(consoleSpy).toHaveBeenCalled();
|
|
171
178
|
consoleSpy.mockRestore();
|
|
172
179
|
});
|
|
180
|
+
|
|
181
|
+
test('should clear both CRM user and RC extension session rows when rcExtensionId is provided', async () => {
|
|
182
|
+
const mockUser = {
|
|
183
|
+
id: 'test-user-id',
|
|
184
|
+
platform: 'testCRM'
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
jwt.decodeJwt.mockReturnValue({
|
|
188
|
+
id: 'test-user-id',
|
|
189
|
+
platform: 'testCRM'
|
|
190
|
+
});
|
|
191
|
+
UserModel.findByPk.mockResolvedValue(mockUser);
|
|
192
|
+
connectorRegistry.getConnector.mockReturnValue({
|
|
193
|
+
unAuthorize: jest.fn().mockResolvedValue({})
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const result = await logout.execute({
|
|
197
|
+
jwtToken: 'mock-jwt-token',
|
|
198
|
+
rcExtensionId: 'rc-ext-123'
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(result.success).toBe(true);
|
|
202
|
+
expect(LlmSessionModel.destroy).toHaveBeenNthCalledWith(1, { where: { id: 'test-user-id' } });
|
|
203
|
+
expect(LlmSessionModel.destroy).toHaveBeenNthCalledWith(2, { where: { id: 'rc-ext-123' } });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('should clear the resolved rcExtension cache for the current OpenAI session on logout', async () => {
|
|
207
|
+
const mockUser = {
|
|
208
|
+
id: 'test-user-id',
|
|
209
|
+
platform: 'testCRM'
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
jwt.decodeJwt.mockReturnValue({
|
|
213
|
+
id: 'test-user-id',
|
|
214
|
+
platform: 'testCRM'
|
|
215
|
+
});
|
|
216
|
+
UserModel.findByPk.mockResolvedValue(mockUser);
|
|
217
|
+
connectorRegistry.getConnector.mockReturnValue({
|
|
218
|
+
unAuthorize: jest.fn().mockResolvedValue({})
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const result = await logout.execute({
|
|
222
|
+
jwtToken: 'mock-jwt-token',
|
|
223
|
+
openaiSessionId: 'oa-session-123'
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(result.success).toBe(true);
|
|
227
|
+
expect(CacheModel.destroy).toHaveBeenCalledWith({
|
|
228
|
+
where: { id: 'oa-session-123-rcExtensionId' }
|
|
229
|
+
});
|
|
230
|
+
});
|
|
173
231
|
});
|
|
174
232
|
});
|
|
175
233
|
|