@app-connect/core 1.7.22 → 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/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}-rcExtensionId`);
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}-rcExtensionId`,
124
+ id: `${openaiSessionId}-${RC_EXTENSION_CACHE_KEY}`,
122
125
  userId: openaiSessionId,
123
- cacheKey: 'rcExtensionId',
126
+ cacheKey: RC_EXTENSION_CACHE_KEY,
124
127
  data: { rcExtensionId },
125
- status: 'active',
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
- await LlmSessionModel.upsert({ id: rcExtensionId, jwtToken: fallback.jwtToken });
210
- llmSession = fallback;
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
- else {
225
+ if (!llmSession?.jwtToken) {
213
226
  const hashedRcExtensionId = getHashValue(rcExtensionId, process.env.HASH_KEY);
214
- const user = await UserModel.findOne({ where: { hashedRcExtensionId } });
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. Using it as the DB key binds the CRM credential to a verified
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
- // Guard against duplicate DB writes if polled concurrently
58
- try {
59
- await LlmSessionModel.create({
60
- id: rcExtensionId,
61
- jwtToken: session.jwtToken
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({ where: { hashedRcExtensionId } });
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;
@@ -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,
@@ -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)) {
@@ -10,5 +10,8 @@ exports.LlmSessionModel = sequelize.define('llmSessions', {
10
10
  },
11
11
  jwtToken: {
12
12
  type: Sequelize.STRING,
13
+ },
14
+ expiry: {
15
+ type: Sequelize.DATE
13
16
  }
14
17
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-connect/core",
3
- "version": "1.7.22",
3
+ "version": "1.7.24",
4
4
  "description": "RingCentral App Connect Core",
5
5
  "main": "index.js",
6
6
  "repository": {
package/releaseNotes.json CHANGED
@@ -1,4 +1,24 @@
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
+ },
14
+ "1.7.23": {
15
+ "global": [
16
+ {
17
+ "type": "Rename",
18
+ "description": "Rename RingSense to ACE"
19
+ }
20
+ ]
21
+ },
2
22
  "1.7.22": {
3
23
  "global": [
4
24
  {
@@ -142,8 +142,7 @@ describe('Admin Handler', () => {
142
142
 
143
143
  expect(result).toEqual({
144
144
  rcAccountId: 'rc-account-789',
145
- rcExtensionId: 'extension-789',
146
- rcUserName: 'Alex Johnson'
145
+ rcExtensionId: 'extension-789'
147
146
  });
148
147
  expect(axios.get).toHaveBeenCalledWith(
149
148
  'https://platform.ringcentral.com/restapi/v1.0/account/~/extension/~',
@@ -24,6 +24,7 @@ jest.mock('../../models/dynamo/connectorSchema', () => ({
24
24
  getProxyConfig: jest.fn()
25
25
  }
26
26
  }));
27
+ jest.mock('axios');
27
28
 
28
29
  const logHandler = require('../../handlers/log');
29
30
  const { CallLogModel } = require('../../models/callLogModel');
@@ -34,6 +35,7 @@ const connectorRegistry = require('../../connector/registry');
34
35
  const oauth = require('../../lib/oauth');
35
36
  const { composeCallLog } = require('../../lib/callLogComposer');
36
37
  const { NoteCache } = require('../../models/dynamo/noteCacheSchema');
38
+ const axios = require('axios');
37
39
  const { sequelize } = require('../../models/sequelize');
38
40
 
39
41
  describe('Log Handler', () => {
@@ -61,6 +63,7 @@ describe('Log Handler', () => {
61
63
  id: 'test-user-id',
62
64
  platform: 'testCRM',
63
65
  accessToken: 'test-access-token',
66
+ rcAccountId: '12345',
64
67
  platformAdditionalInfo: {}
65
68
  };
66
69
 
@@ -217,6 +220,63 @@ describe('Log Handler', () => {
217
220
  expect(savedLog.thirdPartyLogId).toBe('new-log-123');
218
221
  });
219
222
 
223
+ test('should call plugin with Bearer auth and without query jwt token', async () => {
224
+ await UserModel.create(mockUser);
225
+ await AccountDataModel.create({
226
+ rcAccountId: mockUser.rcAccountId,
227
+ platformName: 'testPlugin',
228
+ dataKey: 'pluginData',
229
+ data: {
230
+ name: 'plugin.sample',
231
+ supportedLogTypes: ['call'],
232
+ isAsync: false,
233
+ endpointUrl: 'https://plugins.example.com/plugin/testPlugin',
234
+ jwtToken: 'plugin-jwt-token'
235
+ }
236
+ });
237
+
238
+ const mockConnector = {
239
+ getAuthType: jest.fn().mockResolvedValue('apiKey'),
240
+ getBasicAuth: jest.fn().mockReturnValue('base64-encoded'),
241
+ getLogFormatType: jest.fn().mockReturnValue('text/plain'),
242
+ createCallLog: jest.fn().mockResolvedValue({
243
+ logId: 'new-log-123',
244
+ returnMessage: { message: 'Call logged', messageType: 'success', ttl: 2000 }
245
+ })
246
+ };
247
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
248
+ composeCallLog.mockReturnValue('Composed log details');
249
+
250
+ axios.post.mockResolvedValue({
251
+ data: {
252
+ ...mockIncomingData,
253
+ note: 'updated by plugin'
254
+ },
255
+ headers: {
256
+ 'x-refreshed-jwt-token': 'refreshed-plugin-jwt'
257
+ }
258
+ });
259
+
260
+ const result = await logHandler.createCallLog({
261
+ platform: 'testCRM',
262
+ userId: 'test-user-id',
263
+ incomingData: mockIncomingData,
264
+ hashedAccountId: 'hashed-123',
265
+ isFromSSCL: false
266
+ });
267
+
268
+ expect(result.successful).toBe(true);
269
+ expect(axios.post).toHaveBeenCalledWith(
270
+ 'https://plugins.example.com/plugin/testPlugin',
271
+ { data: mockIncomingData, config: null },
272
+ {
273
+ headers: {
274
+ Authorization: 'Bearer plugin-jwt-token'
275
+ }
276
+ }
277
+ );
278
+ });
279
+
220
280
  test('should successfully create call log with oauth auth', async () => {
221
281
  // Arrange
222
282
  const oauthUser = { ...mockUser };
@@ -9,18 +9,24 @@ jest.mock('../../models/sequelize', () => {
9
9
  }),
10
10
  };
11
11
  });
12
+ jest.mock('axios');
12
13
 
13
14
  const pluginHandler = require('../../handlers/plugin');
14
15
  const { CacheModel } = require('../../models/cacheModel');
16
+ const { AccountDataModel } = require('../../models/accountDataModel');
17
+ const axios = require('axios');
15
18
  const { sequelize } = require('../../models/sequelize');
16
19
 
17
20
  describe('Plugin Handler', () => {
18
21
  beforeAll(async () => {
22
+ process.env.HASH_KEY = 'unit-test-hash-key';
19
23
  await CacheModel.sync({ force: true });
24
+ await AccountDataModel.sync({ force: true });
20
25
  });
21
26
 
22
27
  afterEach(async () => {
23
28
  await CacheModel.destroy({ where: {} });
29
+ await AccountDataModel.destroy({ where: {} });
24
30
  jest.clearAllMocks();
25
31
  });
26
32
 
@@ -283,5 +289,92 @@ describe('Plugin Handler', () => {
283
289
  expect(remainingTask).not.toBeNull();
284
290
  });
285
291
  });
292
+
293
+ describe('registerPluginAccount', () => {
294
+ test('should register plugin account and persist plugin jwt token in account data', async () => {
295
+ const rcAccountId = '12345';
296
+ const pluginId = 'sync-all-caps';
297
+
298
+ axios.get.mockResolvedValue({
299
+ data: {
300
+ platforms: {
301
+ 'plugin.sample': {
302
+ endpointUrl: `https://plugins.example.com/plugin/${pluginId}`,
303
+ userRegisterEndpointUrl: `https://plugins.example.com/plugin/${pluginId}/auth/register`
304
+ }
305
+ }
306
+ }
307
+ });
308
+ axios.post.mockResolvedValue({
309
+ data: {
310
+ jwtToken: 'plugin-jwt-token'
311
+ }
312
+ });
313
+
314
+ const result = await pluginHandler.registerPluginAccount({
315
+ pluginId,
316
+ rcAccessToken: 'rc-access-token',
317
+ rcAccountId,
318
+ pluginAccess: 'public',
319
+ pluginName: 'plugin.sample'
320
+ });
321
+
322
+ expect(result.successful).toBe(true);
323
+ expect(axios.post).toHaveBeenCalledWith(
324
+ `https://plugins.example.com/plugin/${pluginId}/auth/register`,
325
+ {
326
+ rcAccessToken: 'rc-access-token',
327
+ rcAccountId
328
+ }
329
+ );
330
+
331
+ const accountData = await AccountDataModel.findOne({
332
+ where: {
333
+ rcAccountId,
334
+ platformName: pluginId,
335
+ dataKey: 'pluginData'
336
+ }
337
+ });
338
+ expect(accountData).not.toBeNull();
339
+ expect(accountData.data.jwtToken).toBe('plugin-jwt-token');
340
+ expect(accountData.data.endpointUrl).toBe(`https://plugins.example.com/plugin/${pluginId}`);
341
+ });
342
+
343
+ test('should throw when register API does not return jwt token', async () => {
344
+ const rcAccountId = '12345';
345
+ const pluginId = 'sync-all-caps';
346
+
347
+ axios.get.mockResolvedValue({
348
+ data: {
349
+ platforms: {
350
+ 'plugin.sample': {
351
+ endpointUrl: `https://plugins.example.com/plugin/${pluginId}`,
352
+ userRegisterEndpointUrl: `https://plugins.example.com/plugin/${pluginId}/auth/register`
353
+ }
354
+ }
355
+ }
356
+ });
357
+ axios.post.mockResolvedValue({ data: {} });
358
+
359
+ await expect(pluginHandler.registerPluginAccount({
360
+ pluginId,
361
+ rcAccessToken: 'rc-access-token',
362
+ rcAccountId,
363
+ pluginAccess: 'public',
364
+ pluginName: 'plugin.sample'
365
+ })).rejects.toThrow('Plugin register API did not return jwtToken');
366
+ });
367
+ });
368
+
369
+ describe('token header helper', () => {
370
+ test('should parse refreshed jwt token from response headers', () => {
371
+ const token = pluginHandler.getRefreshedJwtTokenFromHeaders({
372
+ headers: {
373
+ 'x-refreshed-jwt-token': 'new-plugin-token'
374
+ }
375
+ });
376
+ expect(token).toBe('new-plugin-token');
377
+ });
378
+ });
286
379
  });
287
380