clementine-agent 1.9.1 → 1.10.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/dist/agent/assistant.js +24 -7
- package/dist/cli/dashboard.js +318 -2
- package/dist/cli/setup.js +13 -0
- package/dist/config/integrations-registry.js +13 -0
- package/dist/integrations/composio/client.d.ts +73 -0
- package/dist/integrations/composio/client.js +440 -0
- package/dist/integrations/composio/mcp-bridge.d.ts +24 -0
- package/dist/integrations/composio/mcp-bridge.js +76 -0
- package/package.json +3 -1
package/dist/agent/assistant.js
CHANGED
|
@@ -1639,7 +1639,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1639
1639
|
};
|
|
1640
1640
|
}
|
|
1641
1641
|
// ── Build SDK Options ─────────────────────────────────────────────
|
|
1642
|
-
buildOptions(opts = {}) {
|
|
1642
|
+
async buildOptions(opts = {}) {
|
|
1643
1643
|
const { isHeartbeat = false, cronTier = null, maxTurns = null, model = null, enableTeams = true, retrievalContext = '', profile = null, sessionKey = null, streaming = false, isPlanStep = false, isUnleashed = false, sourceOverride, disableAllTools = false, verboseLevel, abortController, effort, maxBudgetUsd, thinking, outputFormat, stallGuard, intentClassification, } = opts;
|
|
1644
1644
|
let allowedTools = [
|
|
1645
1645
|
'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep',
|
|
@@ -1928,6 +1928,22 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1928
1928
|
}
|
|
1929
1929
|
}
|
|
1930
1930
|
catch { /* non-fatal — run with just Clementine's own server */ }
|
|
1931
|
+
// Composio toolkits — each connected toolkit becomes an in-process MCP
|
|
1932
|
+
// server (mcp__gmail__*, mcp__slack__*, …). Profile-level allowlist
|
|
1933
|
+
// (profile.allowedComposioToolkits) constrains which toolkits this agent
|
|
1934
|
+
// sees; omit it to surface every active connection.
|
|
1935
|
+
let composioMcpServers = {};
|
|
1936
|
+
if (!disableAllTools && !isPlanStep) {
|
|
1937
|
+
try {
|
|
1938
|
+
const { buildComposioMcpServers } = await import('../integrations/composio/mcp-bridge.js');
|
|
1939
|
+
const allowList = profile?.allowedComposioToolkits;
|
|
1940
|
+
composioMcpServers = await buildComposioMcpServers(allowList);
|
|
1941
|
+
}
|
|
1942
|
+
catch (err) {
|
|
1943
|
+
// Composio is purely additive — never block the agent if it fails.
|
|
1944
|
+
logger.debug({ err }, 'Composio MCP servers unavailable');
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1931
1947
|
// Permission mode: always 'bypassPermissions' — this is a daemon/harness with no interactive
|
|
1932
1948
|
// terminal, so 'auto' mode (which requires plan support + human approval) doesn't apply.
|
|
1933
1949
|
const effectivePermissionMode = 'bypassPermissions';
|
|
@@ -1973,6 +1989,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1973
1989
|
},
|
|
1974
1990
|
},
|
|
1975
1991
|
...externalMcpServers,
|
|
1992
|
+
...composioMcpServers,
|
|
1976
1993
|
},
|
|
1977
1994
|
...(abortController ? { abortController } : {}),
|
|
1978
1995
|
maxTurns: effectiveMaxTurns,
|
|
@@ -2602,7 +2619,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2602
2619
|
let contradictionRetried = false;
|
|
2603
2620
|
try {
|
|
2604
2621
|
for (let attempt = 0; attempt <= PersonalAssistant.RATE_LIMIT_MAX_RETRIES; attempt++) {
|
|
2605
|
-
const sdkOptions = this.buildOptions({ model, maxTurns: maxTurnsOverride ?? null, retrievalContext, profile, sessionKey, streaming: !!onText, verboseLevel, abortController, stallGuard, intentClassification, effort: intentClassification?.suggestedEffort });
|
|
2622
|
+
const sdkOptions = await this.buildOptions({ model, maxTurns: maxTurnsOverride ?? null, retrievalContext, profile, sessionKey, streaming: !!onText, verboseLevel, abortController, stallGuard, intentClassification, effort: intentClassification?.suggestedEffort });
|
|
2606
2623
|
// If a project matched, switch cwd so the agent gets its tools/CLAUDE.md
|
|
2607
2624
|
if (matchedProject) {
|
|
2608
2625
|
sdkOptions.cwd = matchedProject.path;
|
|
@@ -3851,7 +3868,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3851
3868
|
// ── Heartbeat / Cron ──────────────────────────────────────────────
|
|
3852
3869
|
async heartbeat(standingInstructions, changesSummary = '', timeContext = '', dedupContext = '', profile) {
|
|
3853
3870
|
setInteractionSource('autonomous');
|
|
3854
|
-
const sdkOptions = this.buildOptions({
|
|
3871
|
+
const sdkOptions = await this.buildOptions({
|
|
3855
3872
|
isHeartbeat: true,
|
|
3856
3873
|
enableTeams: false,
|
|
3857
3874
|
model: MODELS.haiku,
|
|
@@ -3921,7 +3938,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3921
3938
|
// Don't mutate the global — pass source through the closure instead
|
|
3922
3939
|
// Per-step stall guard so concurrent steps don't cross-contaminate
|
|
3923
3940
|
const stepGuard = new StallGuard();
|
|
3924
|
-
const sdkOptions = this.buildOptions({
|
|
3941
|
+
const sdkOptions = await this.buildOptions({
|
|
3925
3942
|
isHeartbeat: false,
|
|
3926
3943
|
cronTier: tier,
|
|
3927
3944
|
maxTurns,
|
|
@@ -3986,7 +4003,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3986
4003
|
// not chat text — pass mode='cron' so high_effort_low_output guard is
|
|
3987
4004
|
// disabled. Loop detection and circular-reasoning checks stay active.
|
|
3988
4005
|
const cronGuard = new StallGuard('cron');
|
|
3989
|
-
const sdkOptions = this.buildOptions({
|
|
4006
|
+
const sdkOptions = await this.buildOptions({
|
|
3990
4007
|
isHeartbeat: true,
|
|
3991
4008
|
cronTier: tier,
|
|
3992
4009
|
maxTurns: maxTurns ?? (tier >= 2 ? 30 : 15),
|
|
@@ -4473,7 +4490,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
4473
4490
|
_setActiveQueryContext({ job: jobName, source: 'unleashed', agentSlug });
|
|
4474
4491
|
// Unleashed phases run side-effect-heavy work; same logic as cron mode.
|
|
4475
4492
|
const phaseGuard = new StallGuard('unleashed');
|
|
4476
|
-
const sdkOptions = this.buildOptions({
|
|
4493
|
+
const sdkOptions = await this.buildOptions({
|
|
4477
4494
|
isHeartbeat: true,
|
|
4478
4495
|
cronTier: tier,
|
|
4479
4496
|
maxTurns: turnsPerPhase,
|
|
@@ -4830,7 +4847,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
4830
4847
|
logger.info({ taskName, phase }, `Team task: starting phase ${phase}`);
|
|
4831
4848
|
setInteractionSource('autonomous');
|
|
4832
4849
|
const teamGuard = new StallGuard();
|
|
4833
|
-
const sdkOptions = this.buildOptions({
|
|
4850
|
+
const sdkOptions = await this.buildOptions({
|
|
4834
4851
|
isHeartbeat: true,
|
|
4835
4852
|
cronTier: 2, // Give full tool access (Bash, Write, Edit)
|
|
4836
4853
|
maxTurns: turnsPerPhase,
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -4386,6 +4386,144 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
4386
4386
|
res.status(500).json({ error: String(err) });
|
|
4387
4387
|
}
|
|
4388
4388
|
});
|
|
4389
|
+
// ── Composio (1000+ third-party services via OAuth broker) ────
|
|
4390
|
+
app.get('/api/composio/status', (_req, res) => {
|
|
4391
|
+
res.json({ enabled: Boolean(process.env.COMPOSIO_API_KEY) });
|
|
4392
|
+
});
|
|
4393
|
+
app.get('/api/composio/toolkits', async (_req, res) => {
|
|
4394
|
+
try {
|
|
4395
|
+
if (!process.env.COMPOSIO_API_KEY) {
|
|
4396
|
+
res.json({ enabled: false, toolkits: [] });
|
|
4397
|
+
return;
|
|
4398
|
+
}
|
|
4399
|
+
const c = await import('../integrations/composio/client.js');
|
|
4400
|
+
const [connected, configured, meta] = await Promise.all([
|
|
4401
|
+
c.listConnectedToolkits(),
|
|
4402
|
+
c.listToolkitSlugsWithAuthConfig(),
|
|
4403
|
+
c.listToolkitMeta(),
|
|
4404
|
+
]);
|
|
4405
|
+
const connectionsBySlug = new Map();
|
|
4406
|
+
for (const conn of connected) {
|
|
4407
|
+
const arr = connectionsBySlug.get(conn.slug) ?? [];
|
|
4408
|
+
arr.push(conn);
|
|
4409
|
+
connectionsBySlug.set(conn.slug, arr);
|
|
4410
|
+
}
|
|
4411
|
+
for (const arr of connectionsBySlug.values()) {
|
|
4412
|
+
arr.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
|
|
4413
|
+
}
|
|
4414
|
+
const toView = (conn) => ({
|
|
4415
|
+
id: conn.connectionId,
|
|
4416
|
+
status: conn.status,
|
|
4417
|
+
alias: conn.alias ?? null,
|
|
4418
|
+
accountLabel: conn.accountLabel ?? null,
|
|
4419
|
+
accountEmail: conn.accountEmail ?? null,
|
|
4420
|
+
accountName: conn.accountName ?? null,
|
|
4421
|
+
accountAvatarUrl: conn.accountAvatarUrl ?? null,
|
|
4422
|
+
createdAt: conn.createdAt ?? null,
|
|
4423
|
+
});
|
|
4424
|
+
const curated = c.CURATED_TOOLKITS.map(t => {
|
|
4425
|
+
const m = meta.get(t.slug);
|
|
4426
|
+
const conns = connectionsBySlug.get(t.slug) ?? [];
|
|
4427
|
+
return {
|
|
4428
|
+
slug: t.slug,
|
|
4429
|
+
displayName: t.displayName,
|
|
4430
|
+
authMode: t.authMode,
|
|
4431
|
+
hasAuthConfig: configured.has(t.slug),
|
|
4432
|
+
logoUrl: m?.logo ?? null,
|
|
4433
|
+
description: m?.description ?? null,
|
|
4434
|
+
toolCount: m?.toolsCount ?? null,
|
|
4435
|
+
connections: conns.map(toView),
|
|
4436
|
+
};
|
|
4437
|
+
});
|
|
4438
|
+
const extras = [...connectionsBySlug.entries()]
|
|
4439
|
+
.filter(([slug]) => !c.CURATED_TOOLKITS.some(t => t.slug === slug))
|
|
4440
|
+
.map(([slug, conns]) => {
|
|
4441
|
+
const m = meta.get(slug);
|
|
4442
|
+
// Non-curated: infer auth mode. If a custom auth config exists,
|
|
4443
|
+
// user set it up themselves (BYO). Otherwise it must be managed.
|
|
4444
|
+
const authMode = configured.has(slug) ? 'byo' : 'managed';
|
|
4445
|
+
return {
|
|
4446
|
+
slug,
|
|
4447
|
+
displayName: m?.name ?? c.displayNameFor(slug),
|
|
4448
|
+
authMode,
|
|
4449
|
+
hasAuthConfig: configured.has(slug),
|
|
4450
|
+
logoUrl: m?.logo ?? null,
|
|
4451
|
+
description: m?.description ?? null,
|
|
4452
|
+
toolCount: m?.toolsCount ?? null,
|
|
4453
|
+
connections: conns.map(toView),
|
|
4454
|
+
};
|
|
4455
|
+
});
|
|
4456
|
+
res.json({ enabled: true, toolkits: [...curated, ...extras] });
|
|
4457
|
+
}
|
|
4458
|
+
catch (err) {
|
|
4459
|
+
res.status(500).json({ error: String(err) });
|
|
4460
|
+
}
|
|
4461
|
+
});
|
|
4462
|
+
app.post('/api/composio/toolkits/:slug/authorize', async (req, res) => {
|
|
4463
|
+
const slug = req.params.slug;
|
|
4464
|
+
const alias = typeof req.body?.alias === 'string' ? req.body.alias : undefined;
|
|
4465
|
+
try {
|
|
4466
|
+
const c = await import('../integrations/composio/client.js');
|
|
4467
|
+
const result = await c.authorizeToolkit(slug, alias ? { alias } : undefined);
|
|
4468
|
+
res.json(result);
|
|
4469
|
+
}
|
|
4470
|
+
catch (err) {
|
|
4471
|
+
const c = await import('../integrations/composio/client.js');
|
|
4472
|
+
if (err instanceof c.ComposioNeedsAuthConfigError) {
|
|
4473
|
+
res.status(409).json({
|
|
4474
|
+
error: err.message,
|
|
4475
|
+
needsAuthConfig: true,
|
|
4476
|
+
toolkit: slug,
|
|
4477
|
+
setupUrl: 'https://platform.composio.dev/auth-configs',
|
|
4478
|
+
});
|
|
4479
|
+
return;
|
|
4480
|
+
}
|
|
4481
|
+
// Composio errors carry useful detail in `.error`, `.message`, and
|
|
4482
|
+
// `.status`; serializing with String() drops everything except the name.
|
|
4483
|
+
// Pull whatever's there and log server-side so we have a paper trail.
|
|
4484
|
+
const e = err;
|
|
4485
|
+
const detail = e?.error
|
|
4486
|
+
? (typeof e.error === 'string' ? e.error : (e.error.message ?? JSON.stringify(e.error)))
|
|
4487
|
+
: (e?.message ?? String(err));
|
|
4488
|
+
console.error(`[composio] authorize ${slug} failed:`, detail, e?.stack ?? '');
|
|
4489
|
+
res.status(500).json({
|
|
4490
|
+
error: detail,
|
|
4491
|
+
toolkit: slug,
|
|
4492
|
+
statusCode: e?.status,
|
|
4493
|
+
});
|
|
4494
|
+
}
|
|
4495
|
+
});
|
|
4496
|
+
app.post('/api/composio/toolkits/:slug/disconnect', async (req, res) => {
|
|
4497
|
+
const connectionId = typeof req.body?.connectionId === 'string' ? req.body.connectionId : '';
|
|
4498
|
+
if (!connectionId) {
|
|
4499
|
+
res.status(400).json({ error: 'connectionId required in body' });
|
|
4500
|
+
return;
|
|
4501
|
+
}
|
|
4502
|
+
try {
|
|
4503
|
+
const c = await import('../integrations/composio/client.js');
|
|
4504
|
+
await c.disconnectToolkit(connectionId);
|
|
4505
|
+
res.json({ ok: true });
|
|
4506
|
+
}
|
|
4507
|
+
catch (err) {
|
|
4508
|
+
res.status(500).json({ error: String(err) });
|
|
4509
|
+
}
|
|
4510
|
+
});
|
|
4511
|
+
app.post('/api/composio/connections/:id/rename', async (req, res) => {
|
|
4512
|
+
const id = req.params.id;
|
|
4513
|
+
const alias = typeof req.body?.alias === 'string' ? req.body.alias.trim() : '';
|
|
4514
|
+
if (!alias) {
|
|
4515
|
+
res.status(400).json({ error: 'alias required in body' });
|
|
4516
|
+
return;
|
|
4517
|
+
}
|
|
4518
|
+
try {
|
|
4519
|
+
const c = await import('../integrations/composio/client.js');
|
|
4520
|
+
await c.renameConnection(id, alias);
|
|
4521
|
+
res.json({ ok: true });
|
|
4522
|
+
}
|
|
4523
|
+
catch (err) {
|
|
4524
|
+
res.status(500).json({ error: String(err) });
|
|
4525
|
+
}
|
|
4526
|
+
});
|
|
4389
4527
|
// ── CRON CRUD routes ──────────────────────────────────────────
|
|
4390
4528
|
app.get('/api/projects', (_req, res) => {
|
|
4391
4529
|
try {
|
|
@@ -4968,6 +5106,13 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
4968
5106
|
{ key: 'MS_USER_EMAIL', label: 'User Email', hint: 'Email address for mail/calendar access' },
|
|
4969
5107
|
],
|
|
4970
5108
|
},
|
|
5109
|
+
{
|
|
5110
|
+
label: 'Composio',
|
|
5111
|
+
keys: [
|
|
5112
|
+
{ key: 'COMPOSIO_API_KEY', label: 'API Key', hint: 'Get one at https://app.composio.dev → Developers. Unlocks 1000+ services (Gmail, Slack, Notion, …) for the agent.', type: 'password' },
|
|
5113
|
+
{ key: 'COMPOSIO_USER_ID', label: 'User ID', hint: 'Optional — keys all connections under this Composio user. Defaults to "clementine-default".' },
|
|
5114
|
+
],
|
|
5115
|
+
},
|
|
4971
5116
|
{
|
|
4972
5117
|
label: 'Salesforce',
|
|
4973
5118
|
keys: [
|
|
@@ -5088,13 +5233,22 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
5088
5233
|
const { setEnable1MContext } = await import('../config.js');
|
|
5089
5234
|
setEnable1MContext(value.toLowerCase() === 'true');
|
|
5090
5235
|
}
|
|
5236
|
+
// Composio: mutate process.env in-place + drop singleton so the very
|
|
5237
|
+
// next /api/composio/* call picks up the new key without a daemon
|
|
5238
|
+
// restart. Without this, "Save key → Connect Gmail" would 503 until
|
|
5239
|
+
// the user restarted, which defeats the dashboard-config UX.
|
|
5240
|
+
if (key === 'COMPOSIO_API_KEY' || key === 'COMPOSIO_USER_ID') {
|
|
5241
|
+
process.env[key] = value;
|
|
5242
|
+
const { resetComposioClient } = await import('../integrations/composio/client.js');
|
|
5243
|
+
resetComposioClient();
|
|
5244
|
+
}
|
|
5091
5245
|
res.json({ ok: true, message: `Updated ${key}` });
|
|
5092
5246
|
}
|
|
5093
5247
|
catch (err) {
|
|
5094
5248
|
res.status(500).json({ error: String(err) });
|
|
5095
5249
|
}
|
|
5096
5250
|
});
|
|
5097
|
-
app.delete('/api/settings/:key', (req, res) => {
|
|
5251
|
+
app.delete('/api/settings/:key', async (req, res) => {
|
|
5098
5252
|
try {
|
|
5099
5253
|
const { key } = req.params;
|
|
5100
5254
|
if (!existsSync(ENV_PATH)) {
|
|
@@ -5105,6 +5259,13 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
5105
5259
|
const re = new RegExp(`^${key}=.*\n?`, 'm');
|
|
5106
5260
|
content = content.replace(re, '');
|
|
5107
5261
|
writeFileSync(ENV_PATH, content);
|
|
5262
|
+
// Hot-reload mirror of the PUT handler — drop process.env entry +
|
|
5263
|
+
// reset Composio client so removal takes effect without a restart.
|
|
5264
|
+
if (key === 'COMPOSIO_API_KEY' || key === 'COMPOSIO_USER_ID') {
|
|
5265
|
+
delete process.env[key];
|
|
5266
|
+
const { resetComposioClient } = await import('../integrations/composio/client.js');
|
|
5267
|
+
resetComposioClient();
|
|
5268
|
+
}
|
|
5108
5269
|
res.json({ ok: true, message: `Removed ${key}` });
|
|
5109
5270
|
}
|
|
5110
5271
|
catch (err) {
|
|
@@ -14325,6 +14486,15 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
14325
14486
|
</p>
|
|
14326
14487
|
</div>
|
|
14327
14488
|
<div class="tab-pane" id="tab-settings-integrations">
|
|
14489
|
+
<div class="card" style="margin-bottom:20px">
|
|
14490
|
+
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
|
|
14491
|
+
<span>Connections (via Composio)</span>
|
|
14492
|
+
<span style="font-size:11px;color:var(--text-muted)">1000+ services — OAuth handled by Composio</span>
|
|
14493
|
+
</div>
|
|
14494
|
+
<div class="card-body" style="padding:16px" id="composio-connections">
|
|
14495
|
+
<div class="empty-state">Loading...</div>
|
|
14496
|
+
</div>
|
|
14497
|
+
</div>
|
|
14328
14498
|
<div class="card" style="margin-bottom:20px">
|
|
14329
14499
|
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
|
|
14330
14500
|
<span>Claude Desktop Integrations</span>
|
|
@@ -15640,7 +15810,7 @@ function switchTab(group, tab) {
|
|
|
15640
15810
|
if (tab === 'learning' && typeof refreshSelfImprove === 'function') refreshSelfImprove();
|
|
15641
15811
|
}
|
|
15642
15812
|
if (group === 'settings') {
|
|
15643
|
-
if (tab === 'integrations') refreshSalesforce();
|
|
15813
|
+
if (tab === 'integrations') { refreshSalesforce(); refreshComposioConnections(); }
|
|
15644
15814
|
if (tab === 'remote') refreshRemoteAccess();
|
|
15645
15815
|
if (tab === 'security') refreshAuthSessions();
|
|
15646
15816
|
if (tab === 'projects' && typeof refreshProjects === 'function') refreshProjects();
|
|
@@ -25574,6 +25744,152 @@ async function refreshClaudeIntegrations(preloaded) {
|
|
|
25574
25744
|
}
|
|
25575
25745
|
}
|
|
25576
25746
|
|
|
25747
|
+
async function refreshComposioConnections() {
|
|
25748
|
+
var container = document.getElementById('composio-connections');
|
|
25749
|
+
if (!container) return;
|
|
25750
|
+
try {
|
|
25751
|
+
var r = await apiFetch('/api/composio/toolkits');
|
|
25752
|
+
var d = await r.json();
|
|
25753
|
+
if (!d.enabled) {
|
|
25754
|
+
container.innerHTML =
|
|
25755
|
+
'<div style="padding:8px 4px">' +
|
|
25756
|
+
'<div style="font-size:13px;margin-bottom:12px">Composio gives Clementine OAuth access to 1000+ services (Gmail, Slack, Notion, GitHub, …) from any channel — Telegram, Discord, dashboard chat. The agent never sees the credentials.</div>' +
|
|
25757
|
+
'<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">Get your free API key at <a href="https://app.composio.dev/developers" target="_blank" style="color:var(--accent)">app.composio.dev/developers</a>, then paste it here:</div>' +
|
|
25758
|
+
'<div style="display:flex;gap:8px;align-items:center">' +
|
|
25759
|
+
'<input type="password" id="composio-key-input" placeholder="cak_..." style="flex:1;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;font-family:monospace">' +
|
|
25760
|
+
'<button class="btn btn-sm btn-primary" onclick="saveComposioApiKey()" style="font-size:12px">Save & Activate</button>' +
|
|
25761
|
+
'</div>' +
|
|
25762
|
+
'<div id="composio-key-status" style="font-size:11px;color:var(--text-muted);margin-top:8px"></div>' +
|
|
25763
|
+
'</div>';
|
|
25764
|
+
var input = document.getElementById('composio-key-input');
|
|
25765
|
+
if (input) input.addEventListener('keydown', function(e) { if (e.key === 'Enter') saveComposioApiKey(); });
|
|
25766
|
+
return;
|
|
25767
|
+
}
|
|
25768
|
+
var toolkits = d.toolkits || [];
|
|
25769
|
+
if (toolkits.length === 0) {
|
|
25770
|
+
container.innerHTML = '<div class="empty-state" style="padding:16px">No toolkits available. Check that your Composio API key is valid.</div>';
|
|
25771
|
+
return;
|
|
25772
|
+
}
|
|
25773
|
+
|
|
25774
|
+
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">';
|
|
25775
|
+
toolkits.forEach(function(t) {
|
|
25776
|
+
var connected = (t.connections || []).filter(function(c) { return c.status === 'ACTIVE'; });
|
|
25777
|
+
var pending = (t.connections || []).filter(function(c) { return c.status !== 'ACTIVE'; });
|
|
25778
|
+
var isConnected = connected.length > 0;
|
|
25779
|
+
var statusColor = isConnected ? 'var(--green)' : (pending.length > 0 ? 'var(--yellow,#f59e0b)' : 'var(--text-muted)');
|
|
25780
|
+
var byo = t.authMode === 'byo' && !t.hasAuthConfig;
|
|
25781
|
+
|
|
25782
|
+
html += '<div style="border:1px solid var(--border);border-radius:8px;padding:12px;background:var(--bg-secondary);display:flex;flex-direction:column;gap:8px">';
|
|
25783
|
+
html += '<div style="display:flex;align-items:center;gap:10px">';
|
|
25784
|
+
if (t.logoUrl) {
|
|
25785
|
+
html += '<img src="' + esc(t.logoUrl) + '" alt="" style="width:24px;height:24px;border-radius:4px;background:#fff;padding:2px;flex-shrink:0;object-fit:contain">';
|
|
25786
|
+
} else {
|
|
25787
|
+
html += '<div style="width:24px;height:24px;border-radius:4px;background:var(--bg-tertiary);flex-shrink:0"></div>';
|
|
25788
|
+
}
|
|
25789
|
+
html += '<div style="flex:1;min-width:0">';
|
|
25790
|
+
html += '<div style="font-size:13px;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(t.displayName) + '</div>';
|
|
25791
|
+
html += '<div style="font-size:10px;color:var(--text-muted)">' + (isConnected ? connected.length + ' account' + (connected.length !== 1 ? 's' : '') : 'Not connected') + '</div>';
|
|
25792
|
+
html += '</div>';
|
|
25793
|
+
html += '<span style="width:8px;height:8px;border-radius:50%;background:' + statusColor + ';flex-shrink:0"></span>';
|
|
25794
|
+
html += '</div>';
|
|
25795
|
+
|
|
25796
|
+
if (connected.length > 0) {
|
|
25797
|
+
html += '<div style="display:flex;flex-direction:column;gap:4px">';
|
|
25798
|
+
connected.forEach(function(c) {
|
|
25799
|
+
var label = c.alias || c.accountLabel || c.accountEmail || c.accountName || 'connected';
|
|
25800
|
+
html += '<div style="display:flex;align-items:center;gap:6px;font-size:11px">';
|
|
25801
|
+
html += '<span style="flex:1;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(label) + '</span>';
|
|
25802
|
+
html += '<button class="btn-sm" onclick="disconnectComposio(\\'' + esc(t.slug) + '\\',\\'' + esc(c.id) + '\\')" style="font-size:10px;padding:2px 6px;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:4px;cursor:pointer">Disconnect</button>';
|
|
25803
|
+
html += '</div>';
|
|
25804
|
+
});
|
|
25805
|
+
html += '</div>';
|
|
25806
|
+
}
|
|
25807
|
+
|
|
25808
|
+
if (byo) {
|
|
25809
|
+
html += '<div style="font-size:10px;color:var(--yellow,#f59e0b);background:rgba(245,158,11,0.08);padding:6px 8px;border-radius:4px;line-height:1.4">Needs a BYO OAuth app — <a href="https://platform.composio.dev/auth-configs" target="_blank" style="color:inherit;text-decoration:underline">set up in Composio</a> first.</div>';
|
|
25810
|
+
}
|
|
25811
|
+
|
|
25812
|
+
html += '<button class="btn btn-sm btn-primary" onclick="connectComposio(\\'' + esc(t.slug) + '\\')" style="font-size:11px">' + (isConnected ? '+ Add another account' : 'Connect') + '</button>';
|
|
25813
|
+
html += '</div>';
|
|
25814
|
+
});
|
|
25815
|
+
html += '</div>';
|
|
25816
|
+
container.innerHTML = html;
|
|
25817
|
+
} catch (e) {
|
|
25818
|
+
container.innerHTML = '<div class="empty-state" style="color:var(--red);padding:16px">Failed to load Composio toolkits: ' + esc(String(e)) + '</div>';
|
|
25819
|
+
}
|
|
25820
|
+
}
|
|
25821
|
+
|
|
25822
|
+
async function saveComposioApiKey() {
|
|
25823
|
+
var input = document.getElementById('composio-key-input');
|
|
25824
|
+
var status = document.getElementById('composio-key-status');
|
|
25825
|
+
if (!input) return;
|
|
25826
|
+
var key = (input.value || '').trim();
|
|
25827
|
+
if (!key) { if (status) { status.textContent = 'Enter a key first.'; status.style.color = 'var(--red)'; } return; }
|
|
25828
|
+
if (status) { status.textContent = 'Saving…'; status.style.color = 'var(--text-muted)'; }
|
|
25829
|
+
try {
|
|
25830
|
+
await apiJson('PUT', '/api/settings/COMPOSIO_API_KEY', { value: key });
|
|
25831
|
+
if (status) { status.textContent = 'Saved. Loading toolkits…'; status.style.color = 'var(--green)'; }
|
|
25832
|
+
toast('Composio API key saved', 'success');
|
|
25833
|
+
setTimeout(refreshComposioConnections, 400);
|
|
25834
|
+
} catch (e) {
|
|
25835
|
+
if (status) { status.textContent = 'Failed to save: ' + e; status.style.color = 'var(--red)'; }
|
|
25836
|
+
toast('Failed to save key: ' + e, 'error');
|
|
25837
|
+
}
|
|
25838
|
+
}
|
|
25839
|
+
|
|
25840
|
+
async function connectComposio(slug) {
|
|
25841
|
+
try {
|
|
25842
|
+
var res = await fetch('/api/composio/toolkits/' + encodeURIComponent(slug) + '/authorize', {
|
|
25843
|
+
method: 'POST',
|
|
25844
|
+
credentials: 'same-origin',
|
|
25845
|
+
headers: { 'content-type': 'application/json' },
|
|
25846
|
+
body: JSON.stringify({}),
|
|
25847
|
+
});
|
|
25848
|
+
var d = await res.json();
|
|
25849
|
+
if (res.status === 409 && d.needsAuthConfig) {
|
|
25850
|
+
toast('This toolkit needs a BYO OAuth app — opening Composio dashboard.', 'warn');
|
|
25851
|
+
window.open(d.setupUrl, '_blank');
|
|
25852
|
+
return;
|
|
25853
|
+
}
|
|
25854
|
+
if (!res.ok) {
|
|
25855
|
+
var reason = d.error || ('HTTP ' + res.status);
|
|
25856
|
+
toast('Connect failed: ' + reason, 'error');
|
|
25857
|
+
console.error('[composio] connect failed', { slug: slug, status: res.status, body: d });
|
|
25858
|
+
return;
|
|
25859
|
+
}
|
|
25860
|
+
if (d.redirectUrl) {
|
|
25861
|
+
window.open(d.redirectUrl, '_blank');
|
|
25862
|
+
toast('Opened ' + slug + ' authorization in a new tab. Refresh after approving.', 'info');
|
|
25863
|
+
// Soft poll to catch the new connection without forcing a manual refresh.
|
|
25864
|
+
setTimeout(refreshComposioConnections, 5000);
|
|
25865
|
+
setTimeout(refreshComposioConnections, 15000);
|
|
25866
|
+
setTimeout(refreshComposioConnections, 30000);
|
|
25867
|
+
} else {
|
|
25868
|
+
toast('Connected ' + slug, 'success');
|
|
25869
|
+
refreshComposioConnections();
|
|
25870
|
+
}
|
|
25871
|
+
} catch (e) { toast('Connect failed: ' + e, 'error'); }
|
|
25872
|
+
}
|
|
25873
|
+
|
|
25874
|
+
async function disconnectComposio(slug, connectionId) {
|
|
25875
|
+
if (!confirm('Disconnect this ' + slug + ' account?')) return;
|
|
25876
|
+
try {
|
|
25877
|
+
var res = await fetch('/api/composio/toolkits/' + encodeURIComponent(slug) + '/disconnect', {
|
|
25878
|
+
method: 'POST',
|
|
25879
|
+
credentials: 'same-origin',
|
|
25880
|
+
headers: { 'content-type': 'application/json' },
|
|
25881
|
+
body: JSON.stringify({ connectionId: connectionId }),
|
|
25882
|
+
});
|
|
25883
|
+
if (!res.ok) {
|
|
25884
|
+
var d = await res.json().catch(function() { return {}; });
|
|
25885
|
+
toast('Disconnect failed: ' + (d.error || res.status), 'error');
|
|
25886
|
+
return;
|
|
25887
|
+
}
|
|
25888
|
+
toast('Disconnected', 'success');
|
|
25889
|
+
refreshComposioConnections();
|
|
25890
|
+
} catch (e) { toast('Disconnect failed: ' + e, 'error'); }
|
|
25891
|
+
}
|
|
25892
|
+
|
|
25577
25893
|
async function refreshMcpServers() {
|
|
25578
25894
|
var container = document.getElementById('mcp-servers-list');
|
|
25579
25895
|
if (!container) return;
|
package/dist/cli/setup.js
CHANGED
|
@@ -176,6 +176,18 @@ const FEATURES = [
|
|
|
176
176
|
},
|
|
177
177
|
],
|
|
178
178
|
},
|
|
179
|
+
{
|
|
180
|
+
value: 'composio',
|
|
181
|
+
name: 'Composio (1000+ services — Gmail, Slack, Notion, GitHub, …)',
|
|
182
|
+
credentials: [
|
|
183
|
+
{
|
|
184
|
+
key: 'COMPOSIO_API_KEY',
|
|
185
|
+
label: 'Composio API key',
|
|
186
|
+
help: `Sign up at ${CYAN}https://app.composio.dev${DIM} (free tier) and copy your key from Developers > API Keys.\n After setup, manage connections in the dashboard: Settings > Integrations.`,
|
|
187
|
+
masked: true,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
179
191
|
{
|
|
180
192
|
value: 'outlook',
|
|
181
193
|
name: 'Outlook (Microsoft Graph — email + calendar)',
|
|
@@ -598,6 +610,7 @@ export async function runSetup() {
|
|
|
598
610
|
{ header: 'Voice', keys: ['GROQ_API_KEY', 'ELEVENLABS_API_KEY', 'ELEVENLABS_VOICE_ID'] },
|
|
599
611
|
{ header: 'Video', keys: ['GOOGLE_API_KEY'] },
|
|
600
612
|
{ header: 'Outlook (Microsoft Graph)', keys: ['MS_TENANT_ID', 'MS_CLIENT_ID', 'MS_CLIENT_SECRET', 'MS_USER_EMAIL'] },
|
|
613
|
+
{ header: 'Composio (1000+ services broker)', keys: ['COMPOSIO_API_KEY', 'COMPOSIO_USER_ID'] },
|
|
601
614
|
{ header: 'Workspace', keys: ['WORKSPACE_DIRS'] },
|
|
602
615
|
{ header: 'Security', keys: ['ALLOW_ALL_USERS'] },
|
|
603
616
|
];
|
|
@@ -189,6 +189,19 @@ export const INTEGRATIONS = [
|
|
|
189
189
|
{ envVar: 'GOOGLE_API_KEY', label: 'Gemini API key', required: true, docUrl: 'https://aistudio.google.com/apikey', persistent: true },
|
|
190
190
|
],
|
|
191
191
|
},
|
|
192
|
+
// ── Multi-service broker ──────────────────────────────────────────
|
|
193
|
+
{
|
|
194
|
+
slug: 'composio',
|
|
195
|
+
label: 'Composio',
|
|
196
|
+
description: 'OAuth + tool execution for 1000+ services (Gmail, Slack, Notion, GitHub, …). Connect toolkits in Settings → Integrations.',
|
|
197
|
+
kind: 'api-key',
|
|
198
|
+
docUrl: 'https://app.composio.dev/developers',
|
|
199
|
+
capabilities: ['Gmail', 'Google Calendar/Drive/Docs', 'Slack', 'Notion', 'GitHub', 'Linear', 'HubSpot', 'Salesforce', 'and 1000+ more'],
|
|
200
|
+
requirements: [
|
|
201
|
+
{ envVar: 'COMPOSIO_API_KEY', label: 'Composio API key', required: true, docUrl: 'https://app.composio.dev/developers', persistent: true },
|
|
202
|
+
{ envVar: 'COMPOSIO_USER_ID', label: 'User ID for Composio connections (defaults to clementine-default)', required: false, persistent: true },
|
|
203
|
+
],
|
|
204
|
+
},
|
|
192
205
|
// ── Payments ──────────────────────────────────────────────────────
|
|
193
206
|
{
|
|
194
207
|
slug: 'stripe',
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composio integration — client + connection management.
|
|
3
|
+
*
|
|
4
|
+
* Composio brokers OAuth and tool execution for 1000+ third-party services
|
|
5
|
+
* (Gmail, Slack, Notion, Linear, GitHub, …). Users authenticate once via
|
|
6
|
+
* Composio's hosted OAuth flow; Composio holds the tokens and refreshes them
|
|
7
|
+
* automatically. Clementine never sees the credentials.
|
|
8
|
+
*
|
|
9
|
+
* This module owns everything except the per-toolkit MCP server construction
|
|
10
|
+
* (see ./mcp-bridge.ts) and the dashboard endpoints (see cli/dashboard.ts).
|
|
11
|
+
*
|
|
12
|
+
* Disabled gracefully when COMPOSIO_API_KEY is not set — every public function
|
|
13
|
+
* returns an empty result, never throws.
|
|
14
|
+
*/
|
|
15
|
+
import { Composio } from '@composio/core';
|
|
16
|
+
import { ClaudeAgentSDKProvider } from '@composio/claude-agent-sdk';
|
|
17
|
+
export type ToolkitAuthMode = 'managed' | 'byo';
|
|
18
|
+
export interface CuratedToolkit {
|
|
19
|
+
slug: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
/** "managed" = Composio hosts the OAuth app — click Connect, it works.
|
|
22
|
+
* "byo" = User must register their own OAuth app on the toolkit's
|
|
23
|
+
* dev portal and add it as an Auth Config in Composio first. */
|
|
24
|
+
authMode: ToolkitAuthMode;
|
|
25
|
+
}
|
|
26
|
+
export declare const CURATED_TOOLKITS: CuratedToolkit[];
|
|
27
|
+
export declare function getComposio(): Composio<ClaudeAgentSDKProvider> | null;
|
|
28
|
+
export declare function isComposioEnabled(): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Discard the cached client + identity cache so the next call to getComposio()
|
|
31
|
+
* picks up a freshly-set COMPOSIO_API_KEY without a daemon restart. Called by
|
|
32
|
+
* the dashboard PUT /api/settings/COMPOSIO_API_KEY handler.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resetComposioClient(): void;
|
|
35
|
+
export declare function getPreferredUserId(): Promise<string>;
|
|
36
|
+
export declare function clementineUserId(): string;
|
|
37
|
+
export declare function displayNameFor(slug: string): string;
|
|
38
|
+
export interface ConnectedToolkit {
|
|
39
|
+
slug: string;
|
|
40
|
+
connectionId: string;
|
|
41
|
+
status: string;
|
|
42
|
+
alias?: string;
|
|
43
|
+
accountLabel?: string;
|
|
44
|
+
accountEmail?: string;
|
|
45
|
+
accountName?: string;
|
|
46
|
+
accountAvatarUrl?: string;
|
|
47
|
+
createdAt?: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function listConnectedToolkits(): Promise<ConnectedToolkit[]>;
|
|
50
|
+
export interface ToolkitMeta {
|
|
51
|
+
slug: string;
|
|
52
|
+
name: string;
|
|
53
|
+
logo?: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
toolsCount?: number;
|
|
56
|
+
}
|
|
57
|
+
export declare function listToolkitMeta(): Promise<Map<string, ToolkitMeta>>;
|
|
58
|
+
export declare function listToolkitSlugsWithAuthConfig(): Promise<Set<string>>;
|
|
59
|
+
export declare class ComposioNeedsAuthConfigError extends Error {
|
|
60
|
+
readonly slug: string;
|
|
61
|
+
readonly underlying: string;
|
|
62
|
+
constructor(slug: string, underlying: string);
|
|
63
|
+
}
|
|
64
|
+
export declare function authorizeToolkit(slug: string, opts?: {
|
|
65
|
+
callbackUrl?: string;
|
|
66
|
+
alias?: string;
|
|
67
|
+
}): Promise<{
|
|
68
|
+
redirectUrl: string | null;
|
|
69
|
+
connectionId: string;
|
|
70
|
+
}>;
|
|
71
|
+
export declare function disconnectToolkit(connectionId: string): Promise<void>;
|
|
72
|
+
export declare function renameConnection(connectionId: string, alias: string): Promise<void>;
|
|
73
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composio integration — client + connection management.
|
|
3
|
+
*
|
|
4
|
+
* Composio brokers OAuth and tool execution for 1000+ third-party services
|
|
5
|
+
* (Gmail, Slack, Notion, Linear, GitHub, …). Users authenticate once via
|
|
6
|
+
* Composio's hosted OAuth flow; Composio holds the tokens and refreshes them
|
|
7
|
+
* automatically. Clementine never sees the credentials.
|
|
8
|
+
*
|
|
9
|
+
* This module owns everything except the per-toolkit MCP server construction
|
|
10
|
+
* (see ./mcp-bridge.ts) and the dashboard endpoints (see cli/dashboard.ts).
|
|
11
|
+
*
|
|
12
|
+
* Disabled gracefully when COMPOSIO_API_KEY is not set — every public function
|
|
13
|
+
* returns an empty result, never throws.
|
|
14
|
+
*/
|
|
15
|
+
import { Composio } from '@composio/core';
|
|
16
|
+
import { ClaudeAgentSDKProvider } from '@composio/claude-agent-sdk';
|
|
17
|
+
import pino from 'pino';
|
|
18
|
+
const logger = pino({ name: 'clementine.composio' });
|
|
19
|
+
// Curated set surfaced in the dashboard. Composio exposes 1000+ — rendering
|
|
20
|
+
// them all is noisy. Users can still connect anything by editing this list.
|
|
21
|
+
export const CURATED_TOOLKITS = [
|
|
22
|
+
{ slug: 'gmail', displayName: 'Gmail', authMode: 'managed' },
|
|
23
|
+
{ slug: 'googlecalendar', displayName: 'Google Calendar', authMode: 'managed' },
|
|
24
|
+
{ slug: 'googledrive', displayName: 'Google Drive', authMode: 'managed' },
|
|
25
|
+
{ slug: 'googlesheets', displayName: 'Google Sheets', authMode: 'managed' },
|
|
26
|
+
{ slug: 'googledocs', displayName: 'Google Docs', authMode: 'managed' },
|
|
27
|
+
{ slug: 'slack', displayName: 'Slack', authMode: 'managed' },
|
|
28
|
+
{ slug: 'github', displayName: 'GitHub', authMode: 'managed' },
|
|
29
|
+
{ slug: 'linear', displayName: 'Linear', authMode: 'managed' },
|
|
30
|
+
{ slug: 'notion', displayName: 'Notion', authMode: 'managed' },
|
|
31
|
+
{ slug: 'hubspot', displayName: 'HubSpot', authMode: 'managed' },
|
|
32
|
+
{ slug: 'salesforce', displayName: 'Salesforce', authMode: 'managed' },
|
|
33
|
+
{ slug: 'discord', displayName: 'Discord', authMode: 'managed' },
|
|
34
|
+
{ slug: 'trello', displayName: 'Trello', authMode: 'managed' },
|
|
35
|
+
{ slug: 'asana', displayName: 'Asana', authMode: 'managed' },
|
|
36
|
+
{ slug: 'jira', displayName: 'Jira', authMode: 'managed' },
|
|
37
|
+
{ slug: 'airtable', displayName: 'Airtable', authMode: 'managed' },
|
|
38
|
+
{ slug: 'figma', displayName: 'Figma', authMode: 'managed' },
|
|
39
|
+
{ slug: 'dropbox', displayName: 'Dropbox', authMode: 'managed' },
|
|
40
|
+
{ slug: 'stripe', displayName: 'Stripe', authMode: 'managed' },
|
|
41
|
+
{ slug: 'supabase', displayName: 'Supabase', authMode: 'managed' },
|
|
42
|
+
{ slug: 'linkedin', displayName: 'LinkedIn', authMode: 'managed' },
|
|
43
|
+
{ slug: 'outlook', displayName: 'Outlook', authMode: 'managed' },
|
|
44
|
+
{ slug: 'onedrive', displayName: 'OneDrive', authMode: 'managed' },
|
|
45
|
+
{ slug: 'zoom', displayName: 'Zoom', authMode: 'managed' },
|
|
46
|
+
{ slug: 'twitter', displayName: 'Twitter / X', authMode: 'byo' },
|
|
47
|
+
];
|
|
48
|
+
const DISPLAY_NAME_BY_SLUG = new Map(CURATED_TOOLKITS.map(t => [t.slug, t.displayName]));
|
|
49
|
+
let singleton = null;
|
|
50
|
+
export function getComposio() {
|
|
51
|
+
if (singleton)
|
|
52
|
+
return singleton;
|
|
53
|
+
const apiKey = process.env.COMPOSIO_API_KEY;
|
|
54
|
+
if (!apiKey)
|
|
55
|
+
return null;
|
|
56
|
+
singleton = new Composio({
|
|
57
|
+
apiKey,
|
|
58
|
+
provider: new ClaudeAgentSDKProvider(),
|
|
59
|
+
});
|
|
60
|
+
return singleton;
|
|
61
|
+
}
|
|
62
|
+
export function isComposioEnabled() {
|
|
63
|
+
return Boolean(process.env.COMPOSIO_API_KEY);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Discard the cached client + identity cache so the next call to getComposio()
|
|
67
|
+
* picks up a freshly-set COMPOSIO_API_KEY without a daemon restart. Called by
|
|
68
|
+
* the dashboard PUT /api/settings/COMPOSIO_API_KEY handler.
|
|
69
|
+
*/
|
|
70
|
+
export function resetComposioClient() {
|
|
71
|
+
singleton = null;
|
|
72
|
+
identityCache.clear();
|
|
73
|
+
toolkitMetaCache = null;
|
|
74
|
+
detectedPreferredUserId = null;
|
|
75
|
+
}
|
|
76
|
+
// Public: same logic as the internal detector, exposed for the MCP bridge so
|
|
77
|
+
// agent sessions land on the right user_id.
|
|
78
|
+
export async function getPreferredUserId() {
|
|
79
|
+
const composio = getComposio();
|
|
80
|
+
if (!composio)
|
|
81
|
+
return clementineUserId();
|
|
82
|
+
return detectPreferredUserId(composio);
|
|
83
|
+
}
|
|
84
|
+
// Default user_id for *new* connections. We list connections without filtering
|
|
85
|
+
// so existing accounts (set up in Composio's web UI under the platform default
|
|
86
|
+
// "default" user_id, or any other label) still surface — but new authorize()
|
|
87
|
+
// calls have to pass *some* user_id, and we want it to match whatever the
|
|
88
|
+
// user already has if possible. detectPreferredUserId() picks the user_id
|
|
89
|
+
// with the most existing connections, falling back to this constant.
|
|
90
|
+
const DEFAULT_NEW_CONNECTION_USER_ID = 'default';
|
|
91
|
+
export function clementineUserId() {
|
|
92
|
+
return process.env.COMPOSIO_USER_ID ?? DEFAULT_NEW_CONNECTION_USER_ID;
|
|
93
|
+
}
|
|
94
|
+
// Cached after first detection — avoids extra API calls per authorize.
|
|
95
|
+
let detectedPreferredUserId = null;
|
|
96
|
+
async function detectPreferredUserId(composio) {
|
|
97
|
+
if (process.env.COMPOSIO_USER_ID)
|
|
98
|
+
return process.env.COMPOSIO_USER_ID;
|
|
99
|
+
if (detectedPreferredUserId)
|
|
100
|
+
return detectedPreferredUserId;
|
|
101
|
+
try {
|
|
102
|
+
const resp = await composio.connectedAccounts.list({ limit: 100 });
|
|
103
|
+
const counts = new Map();
|
|
104
|
+
for (const it of resp.items) {
|
|
105
|
+
const uid = it.userId ?? it.user_id;
|
|
106
|
+
if (typeof uid === 'string' && uid.length > 0) {
|
|
107
|
+
counts.set(uid, (counts.get(uid) ?? 0) + 1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (counts.size > 0) {
|
|
111
|
+
const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
112
|
+
detectedPreferredUserId = top;
|
|
113
|
+
return top;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
logger.debug({ err }, 'detectPreferredUserId failed — using default');
|
|
118
|
+
}
|
|
119
|
+
detectedPreferredUserId = DEFAULT_NEW_CONNECTION_USER_ID;
|
|
120
|
+
return DEFAULT_NEW_CONNECTION_USER_ID;
|
|
121
|
+
}
|
|
122
|
+
export function displayNameFor(slug) {
|
|
123
|
+
return DISPLAY_NAME_BY_SLUG.get(slug) ?? humanize(slug);
|
|
124
|
+
}
|
|
125
|
+
function humanize(slug) {
|
|
126
|
+
return slug
|
|
127
|
+
.split(/[-_]/g)
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
|
|
130
|
+
.join(' ');
|
|
131
|
+
}
|
|
132
|
+
function str(v) {
|
|
133
|
+
return typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
|
134
|
+
}
|
|
135
|
+
function decodeJwtPayload(jwt) {
|
|
136
|
+
try {
|
|
137
|
+
const parts = jwt.split('.');
|
|
138
|
+
if (parts.length < 2)
|
|
139
|
+
return null;
|
|
140
|
+
const payload = parts[1];
|
|
141
|
+
const padded = payload + '==='.slice((payload.length + 3) % 4);
|
|
142
|
+
const b64 = padded.replace(/-/g, '+').replace(/_/g, '/');
|
|
143
|
+
return JSON.parse(Buffer.from(b64, 'base64').toString('utf-8'));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Composio's connected-account state is shaped per toolkit. Pull whatever
|
|
150
|
+
// human identity we can find — OAuth id_tokens (Google et al.) carry
|
|
151
|
+
// email/name/picture; other toolkits stash shop, subdomain, account_id.
|
|
152
|
+
function extractAccountIdentity(state, data) {
|
|
153
|
+
const s = (state && typeof state === 'object' ? state : {}) ?? {};
|
|
154
|
+
const d = (data && typeof data === 'object' ? data : {}) ?? {};
|
|
155
|
+
const out = {};
|
|
156
|
+
const idToken = str(s.id_token) ?? str(d.id_token);
|
|
157
|
+
if (idToken) {
|
|
158
|
+
const payload = decodeJwtPayload(idToken);
|
|
159
|
+
if (payload) {
|
|
160
|
+
out.email = str(payload.email);
|
|
161
|
+
out.name = str(payload.name) ?? str(payload.given_name);
|
|
162
|
+
out.avatarUrl = str(payload.picture);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const src of [d, s]) {
|
|
166
|
+
const profile = (src.user_info && typeof src.user_info === 'object' ? src.user_info : null) ??
|
|
167
|
+
(src.profile && typeof src.profile === 'object' ? src.profile : null);
|
|
168
|
+
if (profile) {
|
|
169
|
+
out.email = out.email ?? str(profile.email);
|
|
170
|
+
out.name = out.name ?? str(profile.name) ?? str(profile.display_name);
|
|
171
|
+
out.avatarUrl = out.avatarUrl ?? str(profile.picture) ?? str(profile.avatar_url);
|
|
172
|
+
}
|
|
173
|
+
out.email = out.email ?? str(src.email);
|
|
174
|
+
out.name = out.name ?? str(src.name) ?? str(src.display_name);
|
|
175
|
+
out.avatarUrl = out.avatarUrl ?? str(src.avatar_url) ?? str(src.picture);
|
|
176
|
+
}
|
|
177
|
+
const fallback = str(s.shop) ??
|
|
178
|
+
str(s.subdomain) ??
|
|
179
|
+
str(s.domain) ??
|
|
180
|
+
str(s.account_id) ??
|
|
181
|
+
str(s.site_name) ??
|
|
182
|
+
str(d.shop) ??
|
|
183
|
+
str(d.subdomain);
|
|
184
|
+
out.label = out.email ?? out.name ?? fallback;
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
function genericProfileParse(d) {
|
|
188
|
+
const first = (...candidates) => {
|
|
189
|
+
for (const c of candidates)
|
|
190
|
+
if (typeof c === 'string' && c.trim())
|
|
191
|
+
return c.trim();
|
|
192
|
+
return undefined;
|
|
193
|
+
};
|
|
194
|
+
const nested = (key) => (d[key] && typeof d[key] === 'object' ? d[key] : {});
|
|
195
|
+
const viewer = nested('viewer');
|
|
196
|
+
const user = nested('user');
|
|
197
|
+
const team = nested('team');
|
|
198
|
+
const profile = nested('profile');
|
|
199
|
+
const email = first(d.email, user.email, viewer.email, profile.email);
|
|
200
|
+
const name = first(d.name, d.login, d.display_name, user.name, viewer.name, profile.name, team.name);
|
|
201
|
+
const avatar = first(d.avatar_url, d.avatarUrl, d.picture, user.avatar_url, viewer.avatarUrl, profile.image);
|
|
202
|
+
return { email, name, avatarUrl: avatar, label: email ?? name };
|
|
203
|
+
}
|
|
204
|
+
const WHOAMI_BY_TOOLKIT = {
|
|
205
|
+
gmail: {
|
|
206
|
+
tool: 'GMAIL_GET_PROFILE',
|
|
207
|
+
arguments: { user_id: 'me' },
|
|
208
|
+
parse: d => {
|
|
209
|
+
const email = typeof d.emailAddress === 'string' ? d.emailAddress : undefined;
|
|
210
|
+
return { email, label: email };
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
github: { tool: 'GITHUB_GET_THE_AUTHENTICATED_USER', arguments: {}, parse: genericProfileParse },
|
|
214
|
+
linear: { tool: 'LINEAR_GET_CURRENT_USER', arguments: {}, parse: genericProfileParse },
|
|
215
|
+
notion: { tool: 'NOTION_GET_ABOUT_ME', arguments: {}, parse: genericProfileParse },
|
|
216
|
+
slack: { tool: 'SLACK_FETCH_TEAM_INFO', arguments: {}, parse: genericProfileParse },
|
|
217
|
+
hubspot: { tool: 'HUBSPOT_GET_ACCOUNT_INFO', arguments: {}, parse: genericProfileParse },
|
|
218
|
+
stripe: { tool: 'STRIPE_GET_ACCOUNT', arguments: {}, parse: genericProfileParse },
|
|
219
|
+
};
|
|
220
|
+
const identityCache = new Map();
|
|
221
|
+
const IDENTITY_TTL_MS = 15 * 60 * 1000;
|
|
222
|
+
async function fetchToolkitIdentity(composio, slug, connectedAccountId) {
|
|
223
|
+
const spec = WHOAMI_BY_TOOLKIT[slug];
|
|
224
|
+
if (!spec)
|
|
225
|
+
return {};
|
|
226
|
+
try {
|
|
227
|
+
const result = await composio.tools.execute(spec.tool, {
|
|
228
|
+
userId: clementineUserId(),
|
|
229
|
+
// Without this, Composio picks the user's *default* connection for the
|
|
230
|
+
// toolkit, so multiple Gmail accounts end up labeled with the same email.
|
|
231
|
+
...(connectedAccountId ? { connectedAccountId } : {}),
|
|
232
|
+
arguments: spec.arguments,
|
|
233
|
+
// Composio requires either a pinned toolkit version or this flag.
|
|
234
|
+
// Skipping pin so a toolkit bump doesn't break identity silently —
|
|
235
|
+
// misses fall through to fallback chain below.
|
|
236
|
+
dangerouslySkipVersionCheck: true,
|
|
237
|
+
});
|
|
238
|
+
if (!result.successful || !result.data)
|
|
239
|
+
return {};
|
|
240
|
+
return spec.parse(result.data);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
logger.warn({ err, slug }, 'whoami fetch failed');
|
|
244
|
+
return {};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function getIdentityFor(composio, id, slug, seed) {
|
|
248
|
+
if (seed.label)
|
|
249
|
+
return seed;
|
|
250
|
+
const cached = identityCache.get(id);
|
|
251
|
+
if (cached && Date.now() - cached.at < IDENTITY_TTL_MS)
|
|
252
|
+
return cached.identity;
|
|
253
|
+
let identity = {};
|
|
254
|
+
try {
|
|
255
|
+
const full = await composio.connectedAccounts.get(id);
|
|
256
|
+
identity = extractAccountIdentity(full.state, full.data);
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
logger.warn({ err, id }, 'failed to fetch full connection for identity');
|
|
260
|
+
}
|
|
261
|
+
if (!identity.label) {
|
|
262
|
+
const whoami = await fetchToolkitIdentity(composio, slug, id);
|
|
263
|
+
if (whoami.label)
|
|
264
|
+
identity = { ...identity, ...whoami };
|
|
265
|
+
}
|
|
266
|
+
identityCache.set(id, { at: Date.now(), identity });
|
|
267
|
+
return identity;
|
|
268
|
+
}
|
|
269
|
+
export async function listConnectedToolkits() {
|
|
270
|
+
const composio = getComposio();
|
|
271
|
+
if (!composio)
|
|
272
|
+
return [];
|
|
273
|
+
try {
|
|
274
|
+
// No userIds filter: a Composio API key is account-scoped, and a personal
|
|
275
|
+
// agent should see every connection on the account regardless of which
|
|
276
|
+
// user_id label it was created under. This is the fix for "I connected X
|
|
277
|
+
// in Composio but it doesn't show up in Clementine."
|
|
278
|
+
const resp = await composio.connectedAccounts.list({ limit: 100 });
|
|
279
|
+
const enriched = await Promise.all(resp.items.map(async (it) => {
|
|
280
|
+
const seed = extractAccountIdentity(it.state, it.data);
|
|
281
|
+
const identity = it.status === 'ACTIVE'
|
|
282
|
+
? await getIdentityFor(composio, it.id, it.toolkit.slug, seed)
|
|
283
|
+
: seed;
|
|
284
|
+
return {
|
|
285
|
+
slug: it.toolkit.slug,
|
|
286
|
+
connectionId: it.id,
|
|
287
|
+
status: it.status,
|
|
288
|
+
alias: it.alias ?? undefined,
|
|
289
|
+
accountLabel: identity.label,
|
|
290
|
+
accountEmail: identity.email,
|
|
291
|
+
accountName: identity.name,
|
|
292
|
+
accountAvatarUrl: identity.avatarUrl,
|
|
293
|
+
createdAt: it.createdAt,
|
|
294
|
+
};
|
|
295
|
+
}));
|
|
296
|
+
return enriched;
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
logger.error({ err }, 'listConnectedToolkits failed');
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
let toolkitMetaCache = null;
|
|
304
|
+
async function fetchAllToolkitMeta() {
|
|
305
|
+
const composio = getComposio();
|
|
306
|
+
if (!composio)
|
|
307
|
+
return new Map();
|
|
308
|
+
const out = new Map();
|
|
309
|
+
try {
|
|
310
|
+
const resp = await composio.toolkits.get({ limit: 500 });
|
|
311
|
+
const items = Array.isArray(resp) ? resp : (resp.items ?? []);
|
|
312
|
+
for (const it of items) {
|
|
313
|
+
out.set(it.slug, {
|
|
314
|
+
slug: it.slug,
|
|
315
|
+
name: it.name,
|
|
316
|
+
logo: it.meta?.logo,
|
|
317
|
+
description: it.meta?.description,
|
|
318
|
+
toolsCount: it.meta?.toolsCount,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
// Backfill curated toolkits the list endpoint omitted (e.g. MCP-only ones).
|
|
322
|
+
await Promise.all(CURATED_TOOLKITS.filter(t => !out.has(t.slug)).map(async (t) => {
|
|
323
|
+
try {
|
|
324
|
+
const full = (await composio.toolkits.get(t.slug));
|
|
325
|
+
out.set(full.slug, {
|
|
326
|
+
slug: full.slug,
|
|
327
|
+
name: full.name,
|
|
328
|
+
logo: full.meta?.logo,
|
|
329
|
+
description: full.meta?.description,
|
|
330
|
+
toolsCount: full.meta?.toolsCount,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
logger.debug({ err, slug: t.slug }, 'meta backfill failed');
|
|
335
|
+
}
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
logger.error({ err }, 'fetchAllToolkitMeta failed');
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
export async function listToolkitMeta() {
|
|
344
|
+
if (!toolkitMetaCache) {
|
|
345
|
+
toolkitMetaCache = fetchAllToolkitMeta().catch(err => {
|
|
346
|
+
logger.error({ err }, 'listToolkitMeta failed');
|
|
347
|
+
toolkitMetaCache = null;
|
|
348
|
+
return new Map();
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return toolkitMetaCache;
|
|
352
|
+
}
|
|
353
|
+
export async function listToolkitSlugsWithAuthConfig() {
|
|
354
|
+
const composio = getComposio();
|
|
355
|
+
if (!composio)
|
|
356
|
+
return new Set();
|
|
357
|
+
try {
|
|
358
|
+
const resp = await composio.authConfigs.list({ limit: 200 });
|
|
359
|
+
return new Set(resp.items.map(it => it.toolkit.slug));
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
logger.error({ err }, 'listToolkitSlugsWithAuthConfig failed');
|
|
363
|
+
return new Set();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// ── Authorize / disconnect ─────────────────────────────────────────────
|
|
367
|
+
export class ComposioNeedsAuthConfigError extends Error {
|
|
368
|
+
slug;
|
|
369
|
+
underlying;
|
|
370
|
+
constructor(slug, underlying) {
|
|
371
|
+
super(`Toolkit "${slug}" needs an auth config — Composio doesn't host a managed OAuth app for it. ` +
|
|
372
|
+
`Add it via the Composio Dashboard: Toolkits → search ${slug} → Add to project → paste your OAuth credentials. ` +
|
|
373
|
+
`https://platform.composio.dev/auth-configs`);
|
|
374
|
+
this.slug = slug;
|
|
375
|
+
this.underlying = underlying;
|
|
376
|
+
this.name = 'ComposioNeedsAuthConfigError';
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
export async function authorizeToolkit(slug, opts) {
|
|
380
|
+
const composio = getComposio();
|
|
381
|
+
if (!composio)
|
|
382
|
+
throw new Error('COMPOSIO_API_KEY not set');
|
|
383
|
+
// 1. Find or create an auth config. session.authorize() doesn't auto-create
|
|
384
|
+
// so we have to pass authConfigId explicitly to connectedAccounts.initiate.
|
|
385
|
+
let authConfigId;
|
|
386
|
+
const existingConfig = (await composio.authConfigs.list({ toolkit: slug })).items[0];
|
|
387
|
+
if (existingConfig) {
|
|
388
|
+
authConfigId = existingConfig.id;
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
try {
|
|
392
|
+
const created = await composio.authConfigs.create(slug, {
|
|
393
|
+
type: 'use_composio_managed_auth',
|
|
394
|
+
name: `${displayNameFor(slug)} Auth Config`,
|
|
395
|
+
});
|
|
396
|
+
authConfigId = created.id;
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
// 400 → Composio doesn't host a managed OAuth app for this toolkit;
|
|
400
|
+
// user must register their own via the Composio dashboard.
|
|
401
|
+
const status = err?.status;
|
|
402
|
+
if (status === 400) {
|
|
403
|
+
throw new ComposioNeedsAuthConfigError(slug, String(err));
|
|
404
|
+
}
|
|
405
|
+
throw err;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// 2. Initiate the connection. allowMultiple if there's already an active
|
|
409
|
+
// connection so we add another account instead of replacing.
|
|
410
|
+
const existing = (await listConnectedToolkits()).filter(c => c.slug === slug && c.status === 'ACTIVE');
|
|
411
|
+
// Reuse whichever user_id already owns connections in this account, so a
|
|
412
|
+
// freshly authorized Gmail lands next to the existing Outlook (etc.) under
|
|
413
|
+
// the same user_id. Falls back to env override or "default".
|
|
414
|
+
const userId = await detectPreferredUserId(composio);
|
|
415
|
+
const conn = await composio.connectedAccounts.initiate(userId, authConfigId, {
|
|
416
|
+
...(existing.length > 0 ? { allowMultiple: true } : {}),
|
|
417
|
+
...(opts?.callbackUrl ? { callbackUrl: opts.callbackUrl } : {}),
|
|
418
|
+
...(opts?.alias ? { alias: opts.alias } : {}),
|
|
419
|
+
});
|
|
420
|
+
return { redirectUrl: conn.redirectUrl ?? null, connectionId: conn.id };
|
|
421
|
+
}
|
|
422
|
+
export async function disconnectToolkit(connectionId) {
|
|
423
|
+
const composio = getComposio();
|
|
424
|
+
if (!composio)
|
|
425
|
+
throw new Error('COMPOSIO_API_KEY not set');
|
|
426
|
+
await composio.connectedAccounts.delete(connectionId);
|
|
427
|
+
identityCache.delete(connectionId);
|
|
428
|
+
}
|
|
429
|
+
export async function renameConnection(connectionId, alias) {
|
|
430
|
+
const composio = getComposio();
|
|
431
|
+
if (!composio)
|
|
432
|
+
throw new Error('COMPOSIO_API_KEY not set');
|
|
433
|
+
// The Composio high-level wrapper only exposes status updates; alias
|
|
434
|
+
// rename lives on the raw client's PATCH endpoint, which is marked
|
|
435
|
+
// protected. Bridge through `as any` — this is a small, well-scoped escape
|
|
436
|
+
// hatch and the alternative (bypassing the wrapper entirely) loses retry
|
|
437
|
+
// and auth handling.
|
|
438
|
+
await composio.client.connectedAccounts.patch(connectionId, { alias });
|
|
439
|
+
}
|
|
440
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composio MCP bridge — converts Composio toolkits into in-process SDK MCP
|
|
3
|
+
* servers that the Claude Agent SDK's `query()` can spawn directly.
|
|
4
|
+
*
|
|
5
|
+
* Why per-toolkit servers (instead of one big "composio" server)?
|
|
6
|
+
* - Per-agent scoping. A scheduler-bot doesn't need GitHub tools; spawning
|
|
7
|
+
* a Calendar-only sub-agent shouldn't drag in 200 tools across 20
|
|
8
|
+
* toolkits.
|
|
9
|
+
* - Tool-name namespacing. Each MCP server registers its tools under its
|
|
10
|
+
* own name, so we get `mcp__gmail__GMAIL_SEND_EMAIL` not
|
|
11
|
+
* `mcp__composio__GMAIL_SEND_EMAIL` collisions across toolkits.
|
|
12
|
+
* - Concurrency. Composio's TS provider opens one session per server, so
|
|
13
|
+
* having one server per toolkit lets unrelated toolkits load in parallel.
|
|
14
|
+
*
|
|
15
|
+
* Returns an empty map when COMPOSIO_API_KEY is unset, so the agent path
|
|
16
|
+
* always works — Composio is purely additive.
|
|
17
|
+
*/
|
|
18
|
+
import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk';
|
|
19
|
+
/**
|
|
20
|
+
* Build SDK MCP server configs for the given toolkit slugs (or all active
|
|
21
|
+
* connected toolkits when omitted). Each toolkit becomes one MCP server.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildComposioMcpServers(slugs?: string[]): Promise<Record<string, McpSdkServerConfigWithInstance>>;
|
|
24
|
+
//# sourceMappingURL=mcp-bridge.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composio MCP bridge — converts Composio toolkits into in-process SDK MCP
|
|
3
|
+
* servers that the Claude Agent SDK's `query()` can spawn directly.
|
|
4
|
+
*
|
|
5
|
+
* Why per-toolkit servers (instead of one big "composio" server)?
|
|
6
|
+
* - Per-agent scoping. A scheduler-bot doesn't need GitHub tools; spawning
|
|
7
|
+
* a Calendar-only sub-agent shouldn't drag in 200 tools across 20
|
|
8
|
+
* toolkits.
|
|
9
|
+
* - Tool-name namespacing. Each MCP server registers its tools under its
|
|
10
|
+
* own name, so we get `mcp__gmail__GMAIL_SEND_EMAIL` not
|
|
11
|
+
* `mcp__composio__GMAIL_SEND_EMAIL` collisions across toolkits.
|
|
12
|
+
* - Concurrency. Composio's TS provider opens one session per server, so
|
|
13
|
+
* having one server per toolkit lets unrelated toolkits load in parallel.
|
|
14
|
+
*
|
|
15
|
+
* Returns an empty map when COMPOSIO_API_KEY is unset, so the agent path
|
|
16
|
+
* always works — Composio is purely additive.
|
|
17
|
+
*/
|
|
18
|
+
import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
|
|
19
|
+
import pino from 'pino';
|
|
20
|
+
import { getComposio, getPreferredUserId, listConnectedToolkits, } from './client.js';
|
|
21
|
+
const logger = pino({ name: 'clementine.composio.mcp' });
|
|
22
|
+
/**
|
|
23
|
+
* Build SDK MCP server configs for the given toolkit slugs (or all active
|
|
24
|
+
* connected toolkits when omitted). Each toolkit becomes one MCP server.
|
|
25
|
+
*/
|
|
26
|
+
export async function buildComposioMcpServers(slugs) {
|
|
27
|
+
const composio = getComposio();
|
|
28
|
+
if (!composio)
|
|
29
|
+
return {};
|
|
30
|
+
const connected = await listConnectedToolkits();
|
|
31
|
+
const activeSlugs = new Set(connected.filter(c => c.status === 'ACTIVE').map(c => c.slug));
|
|
32
|
+
const targetSlugs = slugs?.length
|
|
33
|
+
? slugs.filter(s => activeSlugs.has(s))
|
|
34
|
+
: [...activeSlugs];
|
|
35
|
+
if (targetSlugs.length === 0)
|
|
36
|
+
return {};
|
|
37
|
+
const out = {};
|
|
38
|
+
// Build serially to avoid hammering Composio with parallel session opens
|
|
39
|
+
// on every agent query. Sessions are cached upstream by Composio's SDK,
|
|
40
|
+
// so repeat calls within a process are cheap after the first hit.
|
|
41
|
+
for (const slug of targetSlugs) {
|
|
42
|
+
try {
|
|
43
|
+
out[slug] = await buildOne(composio, slug, connected);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
logger.warn({ err, slug }, 'failed to build Composio MCP server — skipping');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
async function buildOne(composio, slug, connected) {
|
|
52
|
+
// If 2+ active connections for this toolkit, force Composio to require
|
|
53
|
+
// explicit account selection per tool call — otherwise it silently picks
|
|
54
|
+
// the default (newest) account.
|
|
55
|
+
const activeCount = connected.filter(c => c.slug === slug && c.status === 'ACTIVE').length;
|
|
56
|
+
// Look up auth config explicitly. Without this, composio.create() tries to
|
|
57
|
+
// auto-create one and 400s for BYO toolkits (Twitter etc.) that don't have
|
|
58
|
+
// a managed OAuth app available.
|
|
59
|
+
const authConfig = (await composio.authConfigs.list({ toolkit: slug })).items[0];
|
|
60
|
+
const userId = await getPreferredUserId();
|
|
61
|
+
const session = await composio.create(userId, {
|
|
62
|
+
toolkits: [slug],
|
|
63
|
+
manageConnections: false,
|
|
64
|
+
...(authConfig ? { authConfigs: { [slug]: authConfig.id } } : {}),
|
|
65
|
+
...(activeCount >= 2
|
|
66
|
+
? { multiAccount: { enable: true, requireExplicitSelection: true } }
|
|
67
|
+
: {}),
|
|
68
|
+
});
|
|
69
|
+
const tools = await session.tools();
|
|
70
|
+
return createSdkMcpServer({
|
|
71
|
+
name: slug,
|
|
72
|
+
version: '0.1.0',
|
|
73
|
+
tools,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=mcp-bridge.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clementine-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"description": "Clementine — Personal AI Assistant (TypeScript)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@anthropic-ai/claude-agent-sdk": "^0.2.119",
|
|
25
25
|
"@anthropic-ai/sdk": "^0.91.0",
|
|
26
|
+
"@composio/claude-agent-sdk": "^0.8.1",
|
|
27
|
+
"@composio/core": "^0.8.1",
|
|
26
28
|
"@inquirer/prompts": "^7.0.0",
|
|
27
29
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
30
|
"@slack/bolt": "^4.2.0",
|