@customclaw/composio 0.0.6 → 0.0.8

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/dist/client.js CHANGED
@@ -1,4 +1,40 @@
1
1
  import { Composio } from "@composio/core";
2
+ import { normalizeSessionTags, normalizeToolkitList, normalizeToolkitSlug, normalizeToolSlug, normalizeToolSlugList, } from "./utils.js";
3
+ // Heuristic only: token matching may block some benign tools.
4
+ // Use `allowedToolSlugs` to explicitly override specific slugs.
5
+ const DESTRUCTIVE_TOOL_VERBS = new Set([
6
+ "CREATE",
7
+ "DELETE",
8
+ "DESTROY",
9
+ "DISABLE",
10
+ "DISCONNECT",
11
+ "ERASE",
12
+ "MODIFY",
13
+ "PATCH",
14
+ "POST",
15
+ "PUT",
16
+ "REMOVE",
17
+ "RENAME",
18
+ "REPLACE",
19
+ "REVOKE",
20
+ "SEND",
21
+ "SET",
22
+ "TRUNCATE",
23
+ "UNSUBSCRIBE",
24
+ "UPDATE",
25
+ "UPSERT",
26
+ "WRITE",
27
+ ]);
28
+ function isConnectedAccountStatusFilter(value) {
29
+ return [
30
+ "INITIALIZING",
31
+ "INITIATED",
32
+ "ACTIVE",
33
+ "FAILED",
34
+ "EXPIRED",
35
+ "INACTIVE",
36
+ ].includes(value);
37
+ }
2
38
  /**
3
39
  * Composio client wrapper using Tool Router pattern
4
40
  */
@@ -10,7 +46,15 @@ export class ComposioClient {
10
46
  if (!config.apiKey) {
11
47
  throw new Error("Composio API key required. Set COMPOSIO_API_KEY env var or plugins.composio.apiKey in config.");
12
48
  }
13
- this.config = config;
49
+ this.config = {
50
+ ...config,
51
+ allowedToolkits: normalizeToolkitList(config.allowedToolkits),
52
+ blockedToolkits: normalizeToolkitList(config.blockedToolkits),
53
+ sessionTags: normalizeSessionTags(config.sessionTags),
54
+ allowedToolSlugs: normalizeToolSlugList(config.allowedToolSlugs),
55
+ blockedToolSlugs: normalizeToolSlugList(config.blockedToolSlugs),
56
+ readOnlyMode: Boolean(config.readOnlyMode),
57
+ };
14
58
  this.client = new Composio({ apiKey: config.apiKey });
15
59
  }
16
60
  /**
@@ -22,35 +66,169 @@ export class ComposioClient {
22
66
  /**
23
67
  * Get or create a Tool Router session for a user
24
68
  */
25
- async getSession(userId) {
26
- if (this.sessionCache.has(userId)) {
27
- return this.sessionCache.get(userId);
69
+ makeSessionCacheKey(userId, connectedAccounts) {
70
+ if (!connectedAccounts || Object.keys(connectedAccounts).length === 0) {
71
+ return `uid:${userId}`;
72
+ }
73
+ const normalized = Object.entries(connectedAccounts)
74
+ .map(([toolkit, accountId]) => `${normalizeToolkitSlug(toolkit)}=${accountId}`)
75
+ .sort()
76
+ .join(",");
77
+ return `uid:${userId}::ca:${normalized}`;
78
+ }
79
+ normalizeConnectedAccountsOverride(connectedAccounts) {
80
+ if (!connectedAccounts)
81
+ return undefined;
82
+ const normalized = Object.entries(connectedAccounts)
83
+ .map(([toolkit, accountId]) => [normalizeToolkitSlug(toolkit), String(accountId || "").trim()])
84
+ .filter(([toolkit, accountId]) => toolkit.length > 0 && accountId.length > 0);
85
+ if (normalized.length === 0)
86
+ return undefined;
87
+ return Object.fromEntries(normalized);
88
+ }
89
+ buildToolRouterBlockedToolsConfig() {
90
+ const blocked = this.config.blockedToolSlugs;
91
+ if (!blocked || blocked.length === 0)
92
+ return undefined;
93
+ const byToolkit = new Map();
94
+ for (const slug of blocked) {
95
+ const normalizedSlug = normalizeToolSlug(slug);
96
+ const toolkit = normalizeToolkitSlug(normalizedSlug.split("_")[0] || "");
97
+ if (!toolkit)
98
+ continue;
99
+ if (!this.isToolkitAllowed(toolkit))
100
+ continue;
101
+ if (!byToolkit.has(toolkit))
102
+ byToolkit.set(toolkit, new Set());
103
+ byToolkit.get(toolkit).add(normalizedSlug);
104
+ }
105
+ if (byToolkit.size === 0)
106
+ return undefined;
107
+ const tools = {};
108
+ for (const [toolkit, slugs] of byToolkit.entries()) {
109
+ tools[toolkit] = { disable: Array.from(slugs) };
110
+ }
111
+ return tools;
112
+ }
113
+ buildSessionConfig(connectedAccounts) {
114
+ const sessionConfig = {};
115
+ const normalizedConnectedAccounts = this.normalizeConnectedAccountsOverride(connectedAccounts);
116
+ if (normalizedConnectedAccounts) {
117
+ sessionConfig.connectedAccounts = normalizedConnectedAccounts;
118
+ }
119
+ if (this.config.allowedToolkits && this.config.allowedToolkits.length > 0) {
120
+ sessionConfig.toolkits = { enable: this.config.allowedToolkits };
121
+ }
122
+ else if (this.config.blockedToolkits && this.config.blockedToolkits.length > 0) {
123
+ sessionConfig.toolkits = { disable: this.config.blockedToolkits };
124
+ }
125
+ const tags = new Set(this.config.sessionTags || []);
126
+ if (this.config.readOnlyMode)
127
+ tags.add("readOnlyHint");
128
+ if (tags.size > 0) {
129
+ sessionConfig.tags = Array.from(tags);
130
+ }
131
+ const blockedToolsConfig = this.buildToolRouterBlockedToolsConfig();
132
+ if (blockedToolsConfig) {
133
+ sessionConfig.tools = blockedToolsConfig;
134
+ }
135
+ return Object.keys(sessionConfig).length > 0 ? sessionConfig : undefined;
136
+ }
137
+ async getSession(userId, connectedAccounts) {
138
+ const normalizedConnectedAccounts = this.normalizeConnectedAccountsOverride(connectedAccounts);
139
+ const key = this.makeSessionCacheKey(userId, normalizedConnectedAccounts);
140
+ if (this.sessionCache.has(key)) {
141
+ return this.sessionCache.get(key);
142
+ }
143
+ const sessionConfig = this.buildSessionConfig(normalizedConnectedAccounts);
144
+ let session;
145
+ try {
146
+ session = await this.client.toolRouter.create(userId, sessionConfig);
147
+ }
148
+ catch (err) {
149
+ if (!this.shouldRetrySessionWithoutToolkitFilters(err, sessionConfig)) {
150
+ throw err;
151
+ }
152
+ const { toolkits: _removedToolkits, ...retryWithoutToolkits } = sessionConfig ?? {};
153
+ const retryConfig = Object.keys(retryWithoutToolkits).length > 0
154
+ ? retryWithoutToolkits
155
+ : undefined;
156
+ session = await this.client.toolRouter.create(userId, retryConfig);
28
157
  }
29
- const session = await this.client.toolRouter.create(userId);
30
- this.sessionCache.set(userId, session);
158
+ this.sessionCache.set(key, session);
31
159
  return session;
32
160
  }
161
+ shouldRetrySessionWithoutToolkitFilters(err, sessionConfig) {
162
+ const enabledToolkits = sessionConfig?.toolkits?.enable;
163
+ if (!Array.isArray(enabledToolkits) || enabledToolkits.length === 0) {
164
+ return false;
165
+ }
166
+ const message = String(err instanceof Error ? err.message : err || "").toLowerCase();
167
+ return (message.includes("require auth configs but none exist") &&
168
+ message.includes("please specify them in auth_configs"));
169
+ }
170
+ clearUserSessionCache(userId) {
171
+ const prefix = `uid:${userId}`;
172
+ for (const key of this.sessionCache.keys()) {
173
+ if (!key.startsWith(prefix))
174
+ continue;
175
+ this.sessionCache.delete(key);
176
+ }
177
+ }
33
178
  /**
34
179
  * Check if a toolkit is allowed based on config
35
180
  */
36
181
  isToolkitAllowed(toolkit) {
182
+ const normalizedToolkit = normalizeToolkitSlug(toolkit);
183
+ if (!normalizedToolkit)
184
+ return false;
37
185
  const { allowedToolkits, blockedToolkits } = this.config;
38
- if (blockedToolkits?.includes(toolkit.toLowerCase())) {
186
+ if (blockedToolkits?.includes(normalizedToolkit)) {
39
187
  return false;
40
188
  }
41
189
  if (allowedToolkits && allowedToolkits.length > 0) {
42
- return allowedToolkits.includes(toolkit.toLowerCase());
190
+ return allowedToolkits.includes(normalizedToolkit);
43
191
  }
44
192
  return true;
45
193
  }
194
+ isLikelyDestructiveToolSlug(toolSlug) {
195
+ const tokens = normalizeToolSlug(toolSlug)
196
+ .split("_")
197
+ .filter(Boolean);
198
+ return tokens.some((token) => DESTRUCTIVE_TOOL_VERBS.has(token));
199
+ }
200
+ getToolSlugRestrictionError(toolSlug) {
201
+ const normalizedToolSlug = normalizeToolSlug(toolSlug);
202
+ if (!normalizedToolSlug)
203
+ return "tool_slug is required";
204
+ const isExplicitlyAllowed = this.config.allowedToolSlugs?.includes(normalizedToolSlug) ?? false;
205
+ if (this.config.allowedToolSlugs && this.config.allowedToolSlugs.length > 0) {
206
+ if (!isExplicitlyAllowed) {
207
+ return `Tool '${normalizedToolSlug}' is not in allowedToolSlugs`;
208
+ }
209
+ }
210
+ if (this.config.blockedToolSlugs?.includes(normalizedToolSlug)) {
211
+ return `Tool '${normalizedToolSlug}' is blocked by plugin configuration`;
212
+ }
213
+ if (this.config.readOnlyMode && !isExplicitlyAllowed && this.isLikelyDestructiveToolSlug(normalizedToolSlug)) {
214
+ return (`Tool '${normalizedToolSlug}' was blocked by readOnlyMode because it appears to modify data. ` +
215
+ "Disable readOnlyMode or add this slug to allowedToolSlugs if execution is intentional.");
216
+ }
217
+ return undefined;
218
+ }
46
219
  /**
47
220
  * Execute a Tool Router meta-tool
48
221
  */
49
- async executeMetaTool(toolName, args) {
50
- const response = await this.client.client.tools.execute(toolName, {
222
+ async executeMetaTool(sessionId, toolName, args) {
223
+ const response = await this.client.tools.executeMetaTool(toolName, {
224
+ sessionId,
51
225
  arguments: args,
52
226
  });
53
- return response;
227
+ return {
228
+ successful: Boolean(response.successful),
229
+ data: response.data,
230
+ error: response.error ?? undefined,
231
+ };
54
232
  }
55
233
  /**
56
234
  * Search for tools matching a query using COMPOSIO_SEARCH_TOOLS
@@ -58,10 +236,10 @@ export class ComposioClient {
58
236
  async searchTools(query, options) {
59
237
  const userId = this.getUserId(options?.userId);
60
238
  const session = await this.getSession(userId);
239
+ const requestedToolkits = normalizeToolkitList(options?.toolkits);
61
240
  try {
62
- const response = await this.executeMetaTool("COMPOSIO_SEARCH_TOOLS", {
241
+ const response = await this.executeMetaTool(session.sessionId, "COMPOSIO_SEARCH_TOOLS", {
63
242
  queries: [{ use_case: query }],
64
- session: { id: session.sessionId },
65
243
  });
66
244
  if (!response.successful || !response.data) {
67
245
  throw new Error(response.error || "Search failed");
@@ -81,11 +259,11 @@ export class ComposioClient {
81
259
  continue;
82
260
  seenSlugs.add(slug);
83
261
  const schema = toolSchemas[slug];
84
- const toolkit = schema?.toolkit || slug.split("_")[0] || "";
262
+ const toolkit = normalizeToolkitSlug(schema?.toolkit || slug.split("_")[0] || "");
85
263
  if (!this.isToolkitAllowed(toolkit))
86
264
  continue;
87
- if (options?.toolkits && options.toolkits.length > 0) {
88
- if (!options.toolkits.some(t => t.toLowerCase() === toolkit.toLowerCase())) {
265
+ if (requestedToolkits && requestedToolkits.length > 0) {
266
+ if (!requestedToolkits.includes(toolkit)) {
89
267
  continue;
90
268
  }
91
269
  }
@@ -111,23 +289,47 @@ export class ComposioClient {
111
289
  /**
112
290
  * Execute a single tool using COMPOSIO_MULTI_EXECUTE_TOOL
113
291
  */
114
- async executeTool(toolSlug, args, userId) {
292
+ async executeTool(toolSlug, args, userId, connectedAccountId) {
115
293
  const uid = this.getUserId(userId);
116
- const session = await this.getSession(uid);
117
- const toolkit = toolSlug.split("_")[0]?.toLowerCase() || "";
294
+ const normalizedToolSlug = normalizeToolSlug(toolSlug);
295
+ const toolRestrictionError = this.getToolSlugRestrictionError(normalizedToolSlug);
296
+ if (toolRestrictionError) {
297
+ return { success: false, error: toolRestrictionError };
298
+ }
299
+ const toolkit = normalizeToolkitSlug(normalizedToolSlug.split("_")[0] || "");
118
300
  if (!this.isToolkitAllowed(toolkit)) {
119
301
  return {
120
302
  success: false,
121
303
  error: `Toolkit '${toolkit}' is not allowed by plugin configuration`,
122
304
  };
123
305
  }
306
+ const accountResolution = await this.resolveConnectedAccountForExecution({
307
+ toolkit,
308
+ userId: uid,
309
+ connectedAccountId,
310
+ });
311
+ if ("error" in accountResolution) {
312
+ return { success: false, error: accountResolution.error };
313
+ }
314
+ const session = await this.getSession(uid, accountResolution.connectedAccountId
315
+ ? { [toolkit]: accountResolution.connectedAccountId }
316
+ : undefined);
124
317
  try {
125
- const response = await this.executeMetaTool("COMPOSIO_MULTI_EXECUTE_TOOL", {
126
- tools: [{ tool_slug: toolSlug, arguments: args }],
127
- session: { id: session.sessionId },
318
+ const response = await this.executeMetaTool(session.sessionId, "COMPOSIO_MULTI_EXECUTE_TOOL", {
319
+ tools: [{ tool_slug: normalizedToolSlug, arguments: args }],
128
320
  sync_response_to_workbench: false,
129
321
  });
130
322
  if (!response.successful) {
323
+ const recovered = await this.tryExecutionRecovery({
324
+ uid,
325
+ toolSlug: normalizedToolSlug,
326
+ args,
327
+ connectedAccountId: accountResolution.connectedAccountId,
328
+ metaError: response.error,
329
+ metaData: response.data,
330
+ });
331
+ if (recovered)
332
+ return recovered;
131
333
  return { success: false, error: response.error || "Execution failed" };
132
334
  }
133
335
  const results = response.data?.results || [];
@@ -137,6 +339,18 @@ export class ComposioClient {
137
339
  }
138
340
  // Response data is nested under result.response
139
341
  const toolResponse = result.response;
342
+ if (!toolResponse.successful) {
343
+ const recovered = await this.tryExecutionRecovery({
344
+ uid,
345
+ toolSlug: normalizedToolSlug,
346
+ args,
347
+ connectedAccountId: accountResolution.connectedAccountId,
348
+ metaError: toolResponse.error ?? undefined,
349
+ metaData: response.data,
350
+ });
351
+ if (recovered)
352
+ return recovered;
353
+ }
140
354
  return {
141
355
  success: toolResponse.successful,
142
356
  data: toolResponse.data,
@@ -150,6 +364,160 @@ export class ComposioClient {
150
364
  };
151
365
  }
152
366
  }
367
+ async tryExecutionRecovery(params) {
368
+ const directFallback = await this.tryDirectExecutionFallback(params);
369
+ if (directFallback?.success)
370
+ return directFallback;
371
+ const hintedRetry = await this.tryHintedIdentifierRetry({
372
+ ...params,
373
+ additionalError: directFallback?.error,
374
+ });
375
+ if (hintedRetry)
376
+ return hintedRetry;
377
+ return directFallback;
378
+ }
379
+ async tryDirectExecutionFallback(params) {
380
+ if (!this.shouldFallbackToDirectExecution(params.uid, params.metaError, params.metaData)) {
381
+ return null;
382
+ }
383
+ return this.executeDirectTool(params.toolSlug, params.uid, params.args, params.connectedAccountId);
384
+ }
385
+ async executeDirectTool(toolSlug, userId, args, connectedAccountId) {
386
+ try {
387
+ const response = await this.client.tools.execute(toolSlug, {
388
+ userId,
389
+ connectedAccountId,
390
+ arguments: args,
391
+ dangerouslySkipVersionCheck: true,
392
+ });
393
+ return {
394
+ success: Boolean(response?.successful),
395
+ data: response?.data,
396
+ error: response?.error ?? undefined,
397
+ };
398
+ }
399
+ catch (err) {
400
+ return {
401
+ success: false,
402
+ error: err instanceof Error ? err.message : String(err),
403
+ };
404
+ }
405
+ }
406
+ async tryHintedIdentifierRetry(params) {
407
+ const combined = this.buildCombinedErrorText(params.metaError, params.metaData, params.additionalError);
408
+ if (!this.shouldRetryFromServerHint(combined))
409
+ return null;
410
+ const hint = this.extractServerHintLiteral(combined);
411
+ if (!hint)
412
+ return null;
413
+ const retryArgs = this.buildRetryArgsFromHint(params.args, combined, hint);
414
+ if (!retryArgs)
415
+ return null;
416
+ return this.executeDirectTool(params.toolSlug, params.uid, retryArgs, params.connectedAccountId);
417
+ }
418
+ shouldFallbackToDirectExecution(uid, metaError, metaData) {
419
+ if (uid === "default")
420
+ return false;
421
+ const combined = this.buildCombinedErrorText(metaError, metaData).toLowerCase();
422
+ return combined.includes("no connected account found for entity id default");
423
+ }
424
+ shouldRetryFromServerHint(errorText) {
425
+ const lower = errorText.toLowerCase();
426
+ return (lower.includes("only allowed to access") ||
427
+ lower.includes("allowed to access the"));
428
+ }
429
+ extractServerHintLiteral(errorText) {
430
+ const matches = errorText.match(/`([^`]+)`/);
431
+ if (!matches?.[1])
432
+ return undefined;
433
+ const literal = matches[1].trim();
434
+ if (!literal)
435
+ return undefined;
436
+ if (literal.length > 64)
437
+ return undefined;
438
+ if (/\s/.test(literal))
439
+ return undefined;
440
+ return literal;
441
+ }
442
+ buildRetryArgsFromHint(args, errorText, hint) {
443
+ const stringEntries = Object.entries(args).filter(([, value]) => typeof value === "string");
444
+ if (stringEntries.length === 1) {
445
+ const [field, current] = stringEntries[0];
446
+ if (current === hint)
447
+ return null;
448
+ return { ...args, [field]: hint };
449
+ }
450
+ if (stringEntries.length === 0) {
451
+ const missing = this.extractSingleMissingField(errorText);
452
+ if (!missing)
453
+ return null;
454
+ return { ...args, [missing]: hint };
455
+ }
456
+ return null;
457
+ }
458
+ extractSingleMissingField(errorText) {
459
+ const match = errorText.match(/following fields are missing:\s*\{([^}]+)\}/i);
460
+ const raw = match?.[1];
461
+ if (!raw)
462
+ return undefined;
463
+ const fields = raw
464
+ .split(",")
465
+ .map(part => part.trim().replace(/^['"]|['"]$/g, ""))
466
+ .filter(Boolean);
467
+ return fields.length === 1 ? fields[0] : undefined;
468
+ }
469
+ buildCombinedErrorText(metaError, metaData, additionalError) {
470
+ return [metaError, this.extractNestedMetaError(metaData), additionalError]
471
+ .map(v => String(v || "").trim())
472
+ .filter(Boolean)
473
+ .join("\n");
474
+ }
475
+ extractNestedMetaError(metaData) {
476
+ const results = metaData?.results || [];
477
+ const first = results[0];
478
+ return String(first?.error || "");
479
+ }
480
+ async resolveConnectedAccountForExecution(params) {
481
+ const toolkit = normalizeToolkitSlug(params.toolkit);
482
+ const { userId } = params;
483
+ const explicitId = params.connectedAccountId?.trim();
484
+ if (explicitId) {
485
+ try {
486
+ const account = await this.client.connectedAccounts.get(explicitId);
487
+ const accountToolkit = normalizeToolkitSlug(String(account?.toolkit?.slug || ""));
488
+ const accountStatus = String(account?.status || "").toUpperCase();
489
+ if (accountToolkit && accountToolkit !== toolkit) {
490
+ return {
491
+ error: `Connected account '${explicitId}' belongs to toolkit '${accountToolkit}', but tool '${toolkit}' was requested.`,
492
+ };
493
+ }
494
+ if (accountStatus && accountStatus !== "ACTIVE") {
495
+ return {
496
+ error: `Connected account '${explicitId}' is '${accountStatus}', not ACTIVE.`,
497
+ };
498
+ }
499
+ return { connectedAccountId: explicitId };
500
+ }
501
+ catch (err) {
502
+ return {
503
+ error: `Invalid connected_account_id '${explicitId}': ${err instanceof Error ? err.message : String(err)}`,
504
+ };
505
+ }
506
+ }
507
+ const activeAccounts = await this.listConnectedAccounts({
508
+ toolkits: [toolkit],
509
+ userIds: [userId],
510
+ statuses: ["ACTIVE"],
511
+ });
512
+ if (activeAccounts.length <= 1) {
513
+ return { connectedAccountId: activeAccounts[0]?.id };
514
+ }
515
+ const ids = activeAccounts.map(a => a.id).join(", ");
516
+ return {
517
+ error: `Multiple ACTIVE '${toolkit}' accounts found for user_id '${userId}': ${ids}. ` +
518
+ "Please provide connected_account_id to choose one explicitly.",
519
+ };
520
+ }
153
521
  /**
154
522
  * Get connection status for toolkits using session.toolkits()
155
523
  */
@@ -157,53 +525,270 @@ export class ComposioClient {
157
525
  const uid = this.getUserId(userId);
158
526
  const session = await this.getSession(uid);
159
527
  try {
160
- const response = await session.toolkits();
161
- const allToolkits = response.items || [];
162
- const statuses = [];
163
- if (toolkits && toolkits.length > 0) {
164
- // Check specific toolkits
165
- for (const toolkit of toolkits) {
166
- if (!this.isToolkitAllowed(toolkit))
167
- continue;
168
- const found = allToolkits.find(t => t.slug.toLowerCase() === toolkit.toLowerCase());
169
- statuses.push({
528
+ const normalizedToolkits = normalizeToolkitList(toolkits);
529
+ if (normalizedToolkits && normalizedToolkits.length > 0) {
530
+ const requestedToolkits = normalizedToolkits.filter((t) => this.isToolkitAllowed(t));
531
+ if (requestedToolkits.length === 0)
532
+ return [];
533
+ const toolkitStateMap = await this.getToolkitStateMap(session, requestedToolkits);
534
+ const activeAccountToolkits = await this.getActiveConnectedAccountToolkits(uid, requestedToolkits);
535
+ return requestedToolkits.map((toolkit) => {
536
+ const key = normalizeToolkitSlug(toolkit);
537
+ return {
170
538
  toolkit,
171
- connected: found?.connection?.isActive ?? false,
539
+ connected: (toolkitStateMap.get(key) ?? false) || activeAccountToolkits.has(key),
172
540
  userId: uid,
173
- });
174
- }
541
+ };
542
+ });
175
543
  }
176
- else {
177
- // Return all connected toolkits
178
- for (const tk of allToolkits) {
179
- if (!this.isToolkitAllowed(tk.slug))
180
- continue;
181
- if (!tk.connection?.isActive)
182
- continue;
183
- statuses.push({
184
- toolkit: tk.slug,
185
- connected: true,
186
- userId: uid,
187
- });
188
- }
544
+ const toolkitStateMap = await this.getToolkitStateMap(session);
545
+ const activeAccountToolkits = await this.getActiveConnectedAccountToolkits(uid);
546
+ const connected = new Set();
547
+ for (const [slug, isActive] of toolkitStateMap.entries()) {
548
+ if (!isActive)
549
+ continue;
550
+ if (!this.isToolkitAllowed(slug))
551
+ continue;
552
+ connected.add(slug);
189
553
  }
190
- return statuses;
554
+ for (const slug of activeAccountToolkits) {
555
+ if (!this.isToolkitAllowed(slug))
556
+ continue;
557
+ connected.add(slug);
558
+ }
559
+ return Array.from(connected).map((toolkit) => ({
560
+ toolkit,
561
+ connected: true,
562
+ userId: uid,
563
+ }));
191
564
  }
192
565
  catch (err) {
193
566
  throw new Error(`Failed to get connection status: ${err instanceof Error ? err.message : String(err)}`);
194
567
  }
195
568
  }
569
+ async getToolkitStateMap(session, toolkits) {
570
+ const map = new Map();
571
+ let nextCursor;
572
+ const seenCursors = new Set();
573
+ do {
574
+ const response = await session.toolkits({
575
+ nextCursor,
576
+ limit: 100,
577
+ ...(toolkits && toolkits.length > 0 ? { toolkits } : { isConnected: true }),
578
+ });
579
+ const items = response.items || [];
580
+ for (const tk of items) {
581
+ const key = normalizeToolkitSlug(tk.slug);
582
+ const isActive = tk.connection?.isActive ?? false;
583
+ map.set(key, (map.get(key) ?? false) || isActive);
584
+ }
585
+ nextCursor = response.nextCursor;
586
+ if (!nextCursor)
587
+ break;
588
+ if (seenCursors.has(nextCursor))
589
+ break;
590
+ seenCursors.add(nextCursor);
591
+ } while (true);
592
+ return map;
593
+ }
594
+ async getActiveConnectedAccountToolkits(userId, toolkits) {
595
+ const connected = new Set();
596
+ const normalizedToolkits = normalizeToolkitList(toolkits);
597
+ let cursor;
598
+ const seenCursors = new Set();
599
+ try {
600
+ do {
601
+ const response = await this.client.connectedAccounts.list({
602
+ userIds: [userId],
603
+ statuses: ["ACTIVE"],
604
+ ...(normalizedToolkits && normalizedToolkits.length > 0 ? { toolkitSlugs: normalizedToolkits } : {}),
605
+ limit: 100,
606
+ ...(cursor ? { cursor } : {}),
607
+ });
608
+ const items = (Array.isArray(response)
609
+ ? response
610
+ : response?.items || []);
611
+ for (const item of items) {
612
+ const slug = normalizeToolkitSlug(item.toolkit?.slug || "");
613
+ if (!slug)
614
+ continue;
615
+ if (item.status && String(item.status).toUpperCase() !== "ACTIVE")
616
+ continue;
617
+ connected.add(slug);
618
+ }
619
+ cursor = Array.isArray(response)
620
+ ? null
621
+ : (response?.nextCursor ?? null);
622
+ if (!cursor)
623
+ break;
624
+ if (seenCursors.has(cursor))
625
+ break;
626
+ seenCursors.add(cursor);
627
+ } while (true);
628
+ return connected;
629
+ }
630
+ catch {
631
+ // Best-effort fallback: preserve status checks based on session.toolkits only.
632
+ return connected;
633
+ }
634
+ }
635
+ normalizeStatuses(statuses) {
636
+ if (!statuses || statuses.length === 0)
637
+ return undefined;
638
+ const normalized = statuses
639
+ .map(s => String(s || "").trim().toUpperCase())
640
+ .filter(isConnectedAccountStatusFilter);
641
+ return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
642
+ }
643
+ /**
644
+ * List connected accounts with optional filters.
645
+ * Uses raw API first to preserve user_id in responses, then falls back to SDK-normalized output.
646
+ */
647
+ async listConnectedAccounts(options) {
648
+ const toolkits = normalizeToolkitList(options?.toolkits)?.filter((t) => this.isToolkitAllowed(t));
649
+ const userIds = options?.userIds
650
+ ?.map(u => String(u || "").trim())
651
+ .filter(Boolean);
652
+ const statuses = this.normalizeStatuses(options?.statuses);
653
+ if (options?.toolkits && (!toolkits || toolkits.length === 0))
654
+ return [];
655
+ try {
656
+ return await this.listConnectedAccountsRaw({
657
+ toolkits,
658
+ userIds,
659
+ statuses,
660
+ });
661
+ }
662
+ catch {
663
+ return this.listConnectedAccountsFallback({
664
+ toolkits,
665
+ userIds,
666
+ statuses,
667
+ });
668
+ }
669
+ }
670
+ /**
671
+ * Find user IDs that have an active connected account for a toolkit.
672
+ */
673
+ async findActiveUserIdsForToolkit(toolkit) {
674
+ const normalizedToolkit = normalizeToolkitSlug(toolkit);
675
+ if (!this.isToolkitAllowed(normalizedToolkit))
676
+ return [];
677
+ const accounts = await this.listConnectedAccounts({
678
+ toolkits: [normalizedToolkit],
679
+ statuses: ["ACTIVE"],
680
+ });
681
+ const userIds = new Set();
682
+ for (const account of accounts) {
683
+ if (account.userId)
684
+ userIds.add(account.userId);
685
+ }
686
+ return Array.from(userIds).sort();
687
+ }
688
+ async listConnectedAccountsRaw(options) {
689
+ const accounts = [];
690
+ let cursor;
691
+ const seenCursors = new Set();
692
+ do {
693
+ const response = await this.client.client.connectedAccounts.list({
694
+ ...(options?.toolkits && options.toolkits.length > 0 ? { toolkit_slugs: options.toolkits } : {}),
695
+ ...(options?.userIds && options.userIds.length > 0 ? { user_ids: options.userIds } : {}),
696
+ ...(options?.statuses && options.statuses.length > 0 ? { statuses: options.statuses } : {}),
697
+ limit: 100,
698
+ ...(cursor ? { cursor } : {}),
699
+ });
700
+ const items = (Array.isArray(response)
701
+ ? response
702
+ : response?.items || []);
703
+ for (const item of items) {
704
+ const toolkitSlug = normalizeToolkitSlug((item.toolkit?.slug || "").toString());
705
+ if (!toolkitSlug)
706
+ continue;
707
+ if (!this.isToolkitAllowed(toolkitSlug))
708
+ continue;
709
+ accounts.push({
710
+ id: String(item.id || ""),
711
+ toolkit: toolkitSlug,
712
+ userId: typeof item.user_id === "string" ? item.user_id : undefined,
713
+ status: typeof item.status === "string" ? item.status : undefined,
714
+ authConfigId: typeof item.auth_config?.id === "string"
715
+ ? item.auth_config.id
716
+ : undefined,
717
+ isDisabled: typeof item.is_disabled === "boolean" ? item.is_disabled : undefined,
718
+ createdAt: typeof item.created_at === "string" ? item.created_at : undefined,
719
+ updatedAt: typeof item.updated_at === "string" ? item.updated_at : undefined,
720
+ });
721
+ }
722
+ cursor = Array.isArray(response)
723
+ ? null
724
+ : (response?.next_cursor ?? null);
725
+ if (!cursor)
726
+ break;
727
+ if (seenCursors.has(cursor))
728
+ break;
729
+ seenCursors.add(cursor);
730
+ } while (true);
731
+ return accounts;
732
+ }
733
+ async listConnectedAccountsFallback(options) {
734
+ const accounts = [];
735
+ let cursor;
736
+ const seenCursors = new Set();
737
+ do {
738
+ const response = await this.client.connectedAccounts.list({
739
+ ...(options?.toolkits && options.toolkits.length > 0 ? { toolkitSlugs: options.toolkits } : {}),
740
+ ...(options?.userIds && options.userIds.length > 0 ? { userIds: options.userIds } : {}),
741
+ ...(options?.statuses && options.statuses.length > 0 ? { statuses: options.statuses } : {}),
742
+ limit: 100,
743
+ ...(cursor ? { cursor } : {}),
744
+ });
745
+ const items = (Array.isArray(response)
746
+ ? response
747
+ : response?.items || []);
748
+ for (const item of items) {
749
+ const toolkitSlug = normalizeToolkitSlug((item.toolkit?.slug || "").toString());
750
+ if (!toolkitSlug)
751
+ continue;
752
+ if (!this.isToolkitAllowed(toolkitSlug))
753
+ continue;
754
+ accounts.push({
755
+ id: String(item.id || ""),
756
+ toolkit: toolkitSlug,
757
+ status: typeof item.status === "string" ? item.status : undefined,
758
+ authConfigId: typeof item.authConfig?.id === "string"
759
+ ? item.authConfig.id
760
+ : undefined,
761
+ isDisabled: typeof item.isDisabled === "boolean" ? item.isDisabled : undefined,
762
+ createdAt: typeof item.createdAt === "string" ? item.createdAt : undefined,
763
+ updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : undefined,
764
+ });
765
+ }
766
+ cursor = Array.isArray(response)
767
+ ? null
768
+ : (response?.nextCursor ?? null);
769
+ if (!cursor)
770
+ break;
771
+ if (seenCursors.has(cursor))
772
+ break;
773
+ seenCursors.add(cursor);
774
+ } while (true);
775
+ return accounts;
776
+ }
196
777
  /**
197
778
  * Create an auth connection for a toolkit using session.authorize()
198
779
  */
199
780
  async createConnection(toolkit, userId) {
200
781
  const uid = this.getUserId(userId);
201
- if (!this.isToolkitAllowed(toolkit)) {
202
- return { error: `Toolkit '${toolkit}' is not allowed by plugin configuration` };
782
+ const toolkitSlug = normalizeToolkitSlug(toolkit);
783
+ if (!toolkitSlug) {
784
+ return { error: "Toolkit is required" };
785
+ }
786
+ if (!this.isToolkitAllowed(toolkitSlug)) {
787
+ return { error: `Toolkit '${toolkitSlug}' is not allowed by plugin configuration` };
203
788
  }
204
789
  try {
205
790
  const session = await this.getSession(uid);
206
- const result = await session.authorize(toolkit);
791
+ const result = await session.authorize(toolkitSlug);
207
792
  return { authUrl: result.redirectUrl || result.url || "" };
208
793
  }
209
794
  catch (err) {
@@ -219,11 +804,29 @@ export class ComposioClient {
219
804
  const uid = this.getUserId(userId);
220
805
  try {
221
806
  const session = await this.getSession(uid);
222
- const response = await session.toolkits();
223
- const allToolkits = response.items || [];
224
- return allToolkits
225
- .map(tk => tk.slug)
226
- .filter(slug => this.isToolkitAllowed(slug));
807
+ const seen = new Set();
808
+ let nextCursor;
809
+ const seenCursors = new Set();
810
+ do {
811
+ const response = await session.toolkits({
812
+ nextCursor,
813
+ limit: 100,
814
+ });
815
+ const allToolkits = response.items || [];
816
+ for (const tk of allToolkits) {
817
+ const slug = normalizeToolkitSlug(tk.slug);
818
+ if (!this.isToolkitAllowed(slug))
819
+ continue;
820
+ seen.add(slug);
821
+ }
822
+ nextCursor = response.nextCursor;
823
+ if (!nextCursor)
824
+ break;
825
+ if (seenCursors.has(nextCursor))
826
+ break;
827
+ seenCursors.add(nextCursor);
828
+ } while (true);
829
+ return Array.from(seen);
227
830
  }
228
831
  catch (err) {
229
832
  const errObj = err;
@@ -239,18 +842,36 @@ export class ComposioClient {
239
842
  */
240
843
  async disconnectToolkit(toolkit, userId) {
241
844
  const uid = this.getUserId(userId);
845
+ const toolkitSlug = normalizeToolkitSlug(toolkit);
846
+ if (!toolkitSlug) {
847
+ return { success: false, error: "Toolkit is required" };
848
+ }
849
+ if (this.config.readOnlyMode) {
850
+ return {
851
+ success: false,
852
+ error: "Disconnect is blocked by readOnlyMode.",
853
+ };
854
+ }
242
855
  try {
243
- const response = await this.client.connectedAccounts.list({ userId: uid });
244
- const connections = (Array.isArray(response)
245
- ? response
246
- : response?.items || []);
247
- const conn = connections.find(c => c.toolkit?.slug?.toLowerCase() === toolkit.toLowerCase());
248
- if (!conn) {
249
- return { success: false, error: `No connection found for toolkit '${toolkit}'` };
856
+ const activeAccounts = await this.listConnectedAccounts({
857
+ toolkits: [toolkitSlug],
858
+ userIds: [uid],
859
+ statuses: ["ACTIVE"],
860
+ });
861
+ if (activeAccounts.length === 0) {
862
+ return { success: false, error: `No connection found for toolkit '${toolkitSlug}'` };
863
+ }
864
+ if (activeAccounts.length > 1) {
865
+ const ids = activeAccounts.map(a => a.id).join(", ");
866
+ return {
867
+ success: false,
868
+ error: `Multiple ACTIVE '${toolkitSlug}' accounts found for user_id '${uid}': ${ids}. ` +
869
+ "Use the dashboard to disconnect a specific account.",
870
+ };
250
871
  }
251
- await this.client.connectedAccounts.delete({ connectedAccountId: conn.id });
872
+ await this.client.connectedAccounts.delete(activeAccounts[0].id);
252
873
  // Clear session cache to refresh connection status
253
- this.sessionCache.delete(uid);
874
+ this.clearUserSessionCache(uid);
254
875
  return { success: true };
255
876
  }
256
877
  catch (err) {