diff --git a/apps/rowboat/app/actions/composio_actions.ts b/apps/rowboat/app/actions/composio_actions.ts new file mode 100644 index 00000000..b123b80d --- /dev/null +++ b/apps/rowboat/app/actions/composio_actions.ts @@ -0,0 +1,226 @@ +"use server"; +import { z } from "zod"; +import { + listToolkits as libListToolkits, + listTools as libListTools, + getConnectedAccount as libGetConnectedAccount, + deleteConnectedAccount as libDeleteConnectedAccount, + listAuthConfigs as libListAuthConfigs, + createAuthConfig as libCreateAuthConfig, + getToolkit as libGetToolkit, + createConnectedAccount as libCreateConnectedAccount, + getAuthConfig as libGetAuthConfig, + deleteAuthConfig as libDeleteAuthConfig, + ZToolkit, + ZGetToolkitResponse, + ZTool, + ZListResponse, + ZCreateConnectedAccountResponse, + ZAuthScheme, + ZCredentials, +} from "@/app/lib/composio/composio"; +import { ComposioConnectedAccount } from "@/app/lib/types/project_types"; +import { getProjectConfig, projectAuthCheck } from "./project_actions"; +import { projectsCollection } from "../lib/mongodb"; + +const ZCreateCustomConnectedAccountRequest = z.object({ + toolkitSlug: z.string(), + authConfig: z.object({ + authScheme: ZAuthScheme, + credentials: ZCredentials, + }), + callbackUrl: z.string(), +}); + +export async function listToolkits(projectId: string, cursor: string | null = null): Promise>>> { + await projectAuthCheck(projectId); + return await libListToolkits(cursor); +} + +export async function getToolkit(projectId: string, toolkitSlug: string): Promise> { + await projectAuthCheck(projectId); + return await libGetToolkit(toolkitSlug); +} + +export async function listTools(projectId: string, toolkitSlug: string, cursor: string | null = null): Promise>>> { + await projectAuthCheck(projectId); + return await libListTools(toolkitSlug, cursor); +} + +export async function createComposioManagedOauth2ConnectedAccount(projectId: string, toolkitSlug: string, callbackUrl: string): Promise> { + await projectAuthCheck(projectId); + + // fetch managed auth configs + const configs = await libListAuthConfigs(toolkitSlug, null, true); + + // check if managed oauth2 config exists + let authConfigId: string | undefined = undefined; + const authConfig = configs.items.find(config => config.auth_scheme === 'OAUTH2' && config.is_composio_managed); + authConfigId = authConfig?.id; + if (!authConfig) { + // create a new managed oauth2 auth config + const newAuthConfig = await libCreateAuthConfig({ + toolkit: { + slug: toolkitSlug, + }, + auth_config: { + type: 'use_composio_managed_auth', + name: 'composio-managed-oauth2', + }, + }); + authConfigId = newAuthConfig.auth_config.id; + } + if (!authConfigId) { + throw new Error(`No managed oauth2 auth config found for toolkit ${toolkitSlug}`); + } + + // create new connected account + const response = await libCreateConnectedAccount({ + auth_config: { + id: authConfigId, + }, + connection: { + user_id: projectId, + callback_url: callbackUrl, + }, + }); + + // update project with new connected account + const key = `composioConnectedAccounts.${toolkitSlug}`; + const data: z.infer = { + id: response.id, + authConfigId: authConfigId, + status: 'INITIATED', + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + } + await projectsCollection.updateOne({ _id: projectId }, { $set: { [key]: data } }); + + return response; +} + +export async function createCustomConnectedAccount(projectId: string, request: z.infer): Promise> { + await projectAuthCheck(projectId); + + // first, create the auth config + const authConfig = await libCreateAuthConfig({ + toolkit: { + slug: request.toolkitSlug, + }, + auth_config: { + type: 'use_custom_auth', + authScheme: request.authConfig.authScheme, + credentials: request.authConfig.credentials, + name: `pid-${projectId}-${Date.now()}`, + }, + }); + + // then, create the connected account + let state = undefined; + if (request.authConfig.authScheme !== 'OAUTH2') { + state = { + authScheme: request.authConfig.authScheme, + val: { + status: 'ACTIVE' as const, + ...request.authConfig.credentials, + }, + }; + } + const response = await libCreateConnectedAccount({ + auth_config: { + id: authConfig.auth_config.id, + }, + connection: { + state, + user_id: projectId, + callback_url: request.callbackUrl, + }, + }); + + // update project with new connected account + const key = `composioConnectedAccounts.${request.toolkitSlug}`; + const data: z.infer = { + id: response.id, + authConfigId: authConfig.auth_config.id, + status: 'INITIATED', + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + } + await projectsCollection.updateOne({ _id: projectId }, { $set: { [key]: data } }); + + // return the connected account + return response; +} + +export async function syncConnectedAccount(projectId: string, toolkitSlug: string, connectedAccountId: string): Promise> { + await projectAuthCheck(projectId); + + // ensure that the connected account belongs to this project + const project = await getProjectConfig(projectId); + const account = project.composioConnectedAccounts?.[toolkitSlug]; + if (!account || account.id !== connectedAccountId) { + throw new Error(`Connected account ${connectedAccountId} not found in project ${projectId}`); + } + + // if account is already active, nothing to sync + if (account.status === 'ACTIVE') { + return account; + } + + // get the connected account + const response = await libGetConnectedAccount(connectedAccountId); + + // update project with new connected account + const key = `composioConnectedAccounts.${response.toolkit.slug}`; + switch (response.status) { + case 'INITIALIZING': + case 'INITIATED': + account.status = 'INITIATED'; + break; + case 'ACTIVE': + account.status = 'ACTIVE'; + break; + default: + account.status = 'FAILED'; + break; + } + account.lastUpdatedAt = new Date().toISOString(); + await projectsCollection.updateOne({ _id: projectId }, { $set: { [key]: account } }); + + return account; +} + +export async function deleteConnectedAccount(projectId: string, toolkitSlug: string, connectedAccountId: string): Promise { + await projectAuthCheck(projectId); + + // ensure that the connected account belongs to this project + const project = await getProjectConfig(projectId); + const account = project.composioConnectedAccounts?.[toolkitSlug]; + if (!account || account.id !== connectedAccountId) { + throw new Error(`Connected account ${connectedAccountId} not found in project ${projectId} for toolkit ${toolkitSlug}`); + } + + // delete the connected account + await libDeleteConnectedAccount(connectedAccountId); + + // get auth config data + const authConfig = await libGetAuthConfig(account.authConfigId); + + // delete the auth config if it is NOT managed by composio + if (!authConfig.is_composio_managed) { + await libDeleteAuthConfig(account.authConfigId); + } + + // update project with deleted connected account + const key = `composioConnectedAccounts.${toolkitSlug}`; + await projectsCollection.updateOne({ _id: projectId }, { $unset: { [key]: "" } }); + + return true; +} + +export async function updateComposioSelectedTools(projectId: string, tools: z.infer[]): Promise { + await projectAuthCheck(projectId); + + // update project with new selected tools + await projectsCollection.updateOne({ _id: projectId }, { $set: { composioSelectedTools: tools } }); +} \ No newline at end of file diff --git a/apps/rowboat/app/actions/copilot_actions.ts b/apps/rowboat/app/actions/copilot_actions.ts index eed8faca..f2df813e 100644 --- a/apps/rowboat/app/actions/copilot_actions.ts +++ b/apps/rowboat/app/actions/copilot_actions.ts @@ -11,7 +11,7 @@ import { check_query_limit } from "../lib/rate_limiting"; import { QueryLimitError } from "../lib/client_utils"; import { projectAuthCheck } from "./project_actions"; import { redisClient } from "../lib/redis"; -import { fetchProjectMcpTools } from "../lib/project_tools"; +import { collectProjectTools } from "../lib/project_tools"; import { mergeProjectTools } from "../lib/types/project_types"; import { authorizeUserAction, logUsage } from "./billing_actions"; import { USE_BILLING } from "../lib/feature_flags"; @@ -46,12 +46,12 @@ export async function getCopilotResponseStream( } // Get MCP tools from project and merge with workflow tools - const mcpTools = await fetchProjectMcpTools(projectId); + const projectTools = await collectProjectTools(projectId); // Convert workflow to copilot format with both workflow and project tools const wflow = { ...current_workflow_config, - tools: mergeProjectTools(current_workflow_config.tools, mcpTools) + tools: mergeProjectTools(current_workflow_config.tools, projectTools) }; // prepare request @@ -98,12 +98,12 @@ export async function getCopilotAgentInstructions( } // Get MCP tools from project and merge with workflow tools - const mcpTools = await fetchProjectMcpTools(projectId); + const projectTools = await collectProjectTools(projectId); // Convert workflow to copilot format with both workflow and project tools const wflow = { ...current_workflow_config, - tools: mergeProjectTools(current_workflow_config.tools, mcpTools) + tools: mergeProjectTools(current_workflow_config.tools, projectTools) }; // prepare request diff --git a/apps/rowboat/app/actions/mcp_actions.ts b/apps/rowboat/app/actions/mcp_actions.ts index 20d7fe34..2ead9471 100644 --- a/apps/rowboat/app/actions/mcp_actions.ts +++ b/apps/rowboat/app/actions/mcp_actions.ts @@ -296,37 +296,6 @@ export async function getSelectedMcpTools(projectId: string, serverName: string) return server.tools.map(t => t.id); } -export async function listProjectMcpTools(projectId: string): Promise[]> { - await projectAuthCheck(projectId); - - try { - // Get project's MCP servers and their tools - const project = await projectsCollection.findOne({ _id: projectId }); - if (!project?.mcpServers) return []; - - // Convert MCP tools to workflow tools format, but only from ready servers - return project.mcpServers - .filter(server => server.isReady) // Only include tools from ready servers - .flatMap(server => { - return server.tools.map(tool => ({ - name: tool.name, - description: tool.description || "", - parameters: { - type: 'object' as const, - properties: tool.parameters?.properties || {}, - required: tool.parameters?.required || [] - }, - isMcp: true, - mcpServerName: server.name, - mcpServerURL: server.serverUrl, - })); - }); - } catch (error) { - console.error('Error fetching project tools:', error); - return []; - } -} - export async function testMcpTool( projectId: string, serverName: string, diff --git a/apps/rowboat/app/actions/project_actions.ts b/apps/rowboat/app/actions/project_actions.ts index f22a3c0a..b7f34d91 100644 --- a/apps/rowboat/app/actions/project_actions.ts +++ b/apps/rowboat/app/actions/project_actions.ts @@ -14,6 +14,8 @@ import { USE_AUTH } from "../lib/feature_flags"; import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions"; import { authorizeUserAction } from "./billing_actions"; import { Workflow } from "../lib/types/workflow_types"; +import { WorkflowTool } from "../lib/types/workflow_types"; +import { collectProjectTools as libCollectProjectTools } from "../lib/project_tools"; const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || ''; @@ -344,3 +346,8 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise }); return { id: projectId }; } + +export async function collectProjectTools(projectId: string): Promise[]> { + await projectAuthCheck(projectId); + return libCollectProjectTools(projectId); +} \ No newline at end of file diff --git a/apps/rowboat/app/api/v1/[projectId]/chat/route.ts b/apps/rowboat/app/api/v1/[projectId]/chat/route.ts index d1b871bb..ae532823 100644 --- a/apps/rowboat/app/api/v1/[projectId]/chat/route.ts +++ b/apps/rowboat/app/api/v1/[projectId]/chat/route.ts @@ -7,7 +7,7 @@ import { ApiRequest, ApiResponse } from "../../../../lib/types/types"; import { check_query_limit } from "../../../../lib/rate_limiting"; import { PrefixLogger } from "../../../../lib/utils"; import { TestProfile } from "@/app/lib/types/testing_types"; -import { fetchProjectMcpTools } from "@/app/lib/project_tools"; +import { collectProjectTools } from "@/app/lib/project_tools"; import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing"; import { USE_BILLING } from "@/app/lib/feature_flags"; import { getResponse } from "@/app/lib/agents"; @@ -62,7 +62,7 @@ export async function POST( } // fetch project tools - const projectTools = await fetchProjectMcpTools(projectId); + const projectTools = await collectProjectTools(projectId); // if workflow id is provided in the request, use it, else use the published workflow id let workflowId = result.data.workflowId ?? project.publishedWorkflowId; diff --git a/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts b/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts index 47bb0acb..8b4e98ed 100644 --- a/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts +++ b/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts @@ -6,7 +6,7 @@ import { ObjectId, WithId } from "mongodb"; import { authCheck } from "../../../utils"; import { check_query_limit } from "../../../../../../lib/rate_limiting"; import { PrefixLogger } from "../../../../../../lib/utils"; -import { fetchProjectMcpTools } from "@/app/lib/project_tools"; +import { collectProjectTools } from "@/app/lib/project_tools"; import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing"; import { USE_BILLING } from "@/app/lib/feature_flags"; import { getResponse } from "@/app/lib/agents"; @@ -182,7 +182,7 @@ export async function POST( } // fetch project tools - const projectTools = await fetchProjectMcpTools(session.projectId); + const projectTools = await collectProjectTools(session.projectId); // fetch workflow const workflow = await agentWorkflowsCollection.findOne({ diff --git a/apps/rowboat/app/composio/oauth2/callback/page.tsx b/apps/rowboat/app/composio/oauth2/callback/page.tsx new file mode 100644 index 00000000..b9d9586d --- /dev/null +++ b/apps/rowboat/app/composio/oauth2/callback/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { CheckCircle, XCircle } from 'lucide-react'; + +export default function OAuth2CallbackPage() { + const [isVisible, setIsVisible] = useState(false); + const [isError, setIsError] = useState(false); + + useEffect(() => { + // Small delay for smooth animation + const timer = setTimeout(() => setIsVisible(true), 100); + + // Check for error parameters in URL + const urlParams = new URLSearchParams(window.location.search); + const error = urlParams.get('error'); + const errorDescription = urlParams.get('error_description'); + + if (error) { + setIsError(true); + } + + // Send message to parent window that OAuth is complete + if (window.opener) { + window.opener.postMessage({ + type: 'OAUTH_COMPLETE', + success: !error, + error: error || null, + errorDescription: errorDescription || null, + timestamp: Date.now() + }, window.location.origin); + + // Close this window after a short delay + setTimeout(() => { + window.close(); + }, 3000); + } + + return () => clearTimeout(timer); + }, []); + + return ( +
+
+
+ {isError ? ( + + ) : ( + + )} +
+ +

+ {isError ? 'OAuth2 Flow Failed' : 'OAuth2 Flow Completed'} +

+ +

+ {isError + ? 'There was an issue with the authentication. Please try again.' + : 'Your authentication was successful. You can safely close this page now.' + } +

+ +
+ This window will automatically close in a few seconds... +
+
+
+ ); +} diff --git a/apps/rowboat/app/lib/agents.ts b/apps/rowboat/app/lib/agents.ts index bb0d085e..6c6288dc 100644 --- a/apps/rowboat/app/lib/agents.ts +++ b/apps/rowboat/app/lib/agents.ts @@ -6,11 +6,12 @@ import { createOpenAI } from "@ai-sdk/openai"; import { CoreMessage, embed, generateText } from "ai"; import { ObjectId } from "mongodb"; import { z } from "zod"; +import { Composio } from '@composio/core'; // Internal dependencies import { embeddingModel } from '../lib/embedding'; import { getMcpClient } from "./mcp"; -import { dataSourceDocsCollection, dataSourcesCollection } from "./mongodb"; +import { dataSourceDocsCollection, dataSourcesCollection, projectsCollection } from "./mongodb"; import { qdrantClient } from '../lib/qdrant'; import { EmbeddingRecord } from "./types/datasource_types"; import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool } from "./types/workflow_types"; @@ -192,6 +193,44 @@ async function invokeMcpTool( return result; } +// Helper to handle composio tool calls +async function invokeComposioTool( + logger: PrefixLogger, + projectId: string, + name: string, + composioData: z.infer['composioData'] & {}, + input: any, +) { + logger = logger.child(`invokeComposioTool`); + logger.log(`projectId: ${projectId}`); + logger.log(`name: ${name}`); + logger.log(`input: ${JSON.stringify(input)}`); + + const { slug, toolkitSlug, noAuth } = composioData; + + let connectedAccountId: string | undefined = undefined; + if (!noAuth) { + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project) { + throw new Error(`project ${projectId} not found`); + } + connectedAccountId = project.composioConnectedAccounts?.[toolkitSlug]?.id; + if (!connectedAccountId) { + throw new Error(`connected account id not found for project ${projectId} and toolkit ${toolkitSlug}`); + } + } + + const composio = new Composio(); + + const result = await composio.tools.execute(slug, { + userId: projectId, + arguments: input, + connectedAccountId: connectedAccountId, + }); + logger.log(`composio tool result: ${JSON.stringify(result)}`); + return result.data; +} + // Helper to create RAG tool function createRagTool( logger: PrefixLogger, @@ -291,6 +330,44 @@ function createMcpTool( }); } +// Helper to create a composio tool +function createComposioTool( + logger: PrefixLogger, + config: z.infer, + projectId: string +): Tool { + const { name, description, parameters, composioData } = config; + + if (!composioData) { + throw new Error(`composio data not found for tool ${name}`); + } + + return tool({ + name, + description, + strict: false, + parameters: { + type: 'object', + properties: parameters.properties, + required: parameters.required || [], + additionalProperties: true, + }, + async execute(input: any) { + try { + const result = await invokeComposioTool(logger, projectId, name, composioData, input); + return JSON.stringify({ + result, + }); + } catch (error) { + logger.log(`Error executing composio tool ${name}:`, error); + return JSON.stringify({ + error: `Tool execution failed: ${error}`, + }); + } + } + }); +} + // Helper to create an agent function createAgent( logger: PrefixLogger, @@ -594,6 +671,9 @@ function createTools(logger: PrefixLogger, workflow: z.infer, t if (config.isMcp) { tools[toolName] = createMcpTool(logger, config, workflow.projectId); logger.log(`created mcp tool: ${toolName}`); + } else if (config.isComposio) { + tools[toolName] = createComposioTool(logger, config, workflow.projectId); + logger.log(`created composio tool: ${toolName}`); } else if (config.mockTool) { tools[toolName] = createMockTool(logger, config); logger.log(`created mock tool: ${toolName}`); diff --git a/apps/rowboat/app/lib/composio/composio.ts b/apps/rowboat/app/lib/composio/composio.ts new file mode 100644 index 00000000..3441f9fb --- /dev/null +++ b/apps/rowboat/app/lib/composio/composio.ts @@ -0,0 +1,410 @@ +import { z } from "zod"; +import { PrefixLogger } from "../utils"; + +const BASE_URL = 'https://backend.composio.dev/api/v3'; +const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY || ""; + +export const ZAuthScheme = z.enum([ + 'API_KEY', + 'BASIC', + 'BASIC_WITH_JWT', + 'BEARER_TOKEN', + 'BILLCOM_AUTH', + 'CALCOM_AUTH', + 'COMPOSIO_LINK', + 'GOOGLE_SERVICE_ACCOUNT', + 'NO_AUTH', + 'OAUTH1', + 'OAUTH2', +]); + +export const ZConnectedAccountStatus = z.enum([ + 'INITIALIZING', + 'INITIATED', + 'ACTIVE', + 'FAILED', + 'EXPIRED', + 'INACTIVE', +]); + +export const ZToolkit = z.object({ + slug: z.string(), + name: z.string(), + meta: z.object({ + description: z.string(), + logo: z.string(), + tools_count: z.number(), + }), + no_auth: z.boolean(), + auth_schemes: z.array(ZAuthScheme), + composio_managed_auth_schemes: z.array(ZAuthScheme), +}); + +export const ZComposioField = z.object({ + name: z.string(), + displayName: z.string(), + type: z.string(), + description: z.string(), + required: z.boolean(), + default: z.string().nullable().optional(), +}); + +export const ZGetToolkitResponse = z.object({ + slug: z.string(), + name: z.string(), + composio_managed_auth_schemes: z.array(ZAuthScheme), + auth_config_details: z.array(z.object({ + name: z.string(), + mode: ZAuthScheme, + fields: z.object({ + auth_config_creation: z.object({ + required: z.array(ZComposioField), + optional: z.array(ZComposioField), + }), + connected_account_initiation: z.object({ + required: z.array(ZComposioField), + optional: z.array(ZComposioField), + }), + }) + })).nullable(), +}); + +export const ZTool = z.object({ + slug: z.string(), + name: z.string(), + description: z.string(), + toolkit: z.object({ + slug: z.string(), + name: z.string(), + logo: z.string(), + }), + input_parameters: z.object({ + type: z.literal('object'), + properties: z.record(z.string(), z.any()), + required: z.array(z.string()).optional(), + additionalProperties: z.boolean().optional(), + }), + no_auth: z.boolean(), +}); + +export const ZAuthConfig = z.object({ + id: z.string(), + is_composio_managed: z.boolean(), + auth_scheme: ZAuthScheme, +}); + +export const ZCredentials = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])); + +export const ZCreateAuthConfigRequest = z.object({ + toolkit: z.object({ + slug: z.string(), + }), + auth_config: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('use_composio_managed_auth'), + name: z.string().optional(), + credentials: ZCredentials.optional(), + restrict_to_following_tools: z.array(z.string()).optional(), + }), + z.object({ + type: z.literal('use_custom_auth'), + authScheme: ZAuthScheme, + credentials: ZCredentials, + name: z.string().optional(), + proxy_config: z.object({ + proxy_url: z.string(), + proxy_auth_key: z.string().optional(), + }).optional(), + restrict_to_following_tools: z.array(z.string()).optional(), + }), + ]).optional(), +}); + +/* +{ + "toolkit": { + "slug": "github" + }, + "auth_config": { + "id": "ac_ZiLwFAWuGA7G", + "auth_scheme": "OAUTH2", + "is_composio_managed": false, + "restrict_to_following_tools": [] + } +} +*/ +export const ZCreateAuthConfigResponse = z.object({ + toolkit: z.object({ + slug: z.string(), + }), + auth_config: ZAuthConfig, +}); + +const ZConnectionData = z.object({ + authScheme: ZAuthScheme, + val: z.record(z.string(), z.unknown()) + .and(z.object({ + status: ZConnectedAccountStatus, + })), +}); + +export const ZCreateConnectedAccountRequest = z.object({ + auth_config: z.object({ + id: z.string(), + }), + connection: z.object({ + state: ZConnectionData.optional(), + user_id: z.string().optional(), + callback_url: z.string().optional(), + }), +}); + +/* +{ + "id": "ca_vTkCeLZSGab-", + "connectionData": { + "authScheme": "OAUTH2", + "val": { + "status": "INITIATED", + "code_verifier": "cd0103c5d8836a387adab1635b65ff0d2f51f77a1a79b7ff", + "redirectUrl": "https://backend.composio.dev/api/v3/s/DbTOWAyR", + "callback_url": "https://backend.composio.dev/api/v1/auth-apps/add" + } + }, + "status": "INITIATED", + "redirect_url": "https://backend.composio.dev/api/v3/s/DbTOWAyR", + "redirect_uri": "https://backend.composio.dev/api/v3/s/DbTOWAyR", + "deprecated": { + "uuid": "fe66d24b-59d8-4abf-adb2-d8f74353da9e", + "authConfigUuid": "8c4d4c84-56e2-4a80-aa59-9e84503381d8" + } +} +*/ +export const ZCreateConnectedAccountResponse = z.object({ + id: z.string(), + connectionData: ZConnectionData, +}); + +export const ZConnectedAccount = z.object({ + id: z.string(), + toolkit: z.object({ + slug: z.string(), + }), + auth_config: z.object({ + id: z.string(), + is_composio_managed: z.boolean(), + is_disabled: z.boolean(), + }), + status: ZConnectedAccountStatus, +}); + +const ZErrorResponse = z.object({ + error: z.object({ + message: z.string(), + error_code: z.number(), + suggested_fix: z.string().nullable(), + errors: z.array(z.string()).nullable(), + }), +}); + +export const ZError = z.object({ + error: z.enum([ + 'CUSTOM_OAUTH2_CONFIG_REQUIRED', + ]), +}); + +export const ZDeleteOperationResponse = z.object({ + success: z.boolean(), +}); + +export const ZListResponse = (schema: T) => z.object({ + items: z.array(schema), + next_cursor: z.string().nullable(), + total_pages: z.number(), + current_page: z.number(), + total_items: z.number(), +}); + +export async function composioApiCall( + schema: T, + url: string, + options: RequestInit = {}, +): Promise> { + const logger = new PrefixLogger('composioApiCall'); + logger.log(`[${options.method || 'GET'}] ${url}`, options); + + const then = Date.now(); + + try { + const response = await fetch(url, { + ...options, + headers: { + ...options.headers, + "x-api-key": COMPOSIO_API_KEY, + ...(options.method === 'POST' ? { + "Content-Type": "application/json", + } : {}), + }, + }); + const duration = Date.now() - then; + logger.log(`Took: ${duration}ms`); + const data = await response.json(); + if ('error' in data) { + const response = ZErrorResponse.parse(data); + throw new Error(`(code: ${response.error.error_code}): ${response.error.message}: ${response.error.suggested_fix}: ${response.error.errors?.join(', ')}`); + } + return schema.parse(data); + } catch (error) { + logger.log(`Error:`, error); + throw error; + } +} + +export async function listToolkits(cursor: string | null = null): Promise>>> { + const url = new URL(`${BASE_URL}/toolkits`); + + // set params + url.searchParams.set("sort_by", "usage"); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + + // fetch + return composioApiCall(ZListResponse(ZToolkit), url.toString()); +} + +export async function getToolkit(toolkitSlug: string): Promise> { + const url = new URL(`${BASE_URL}/toolkits/${toolkitSlug}`); + return composioApiCall(ZGetToolkitResponse, url.toString()); +} + +export async function listTools(toolkitSlug: string, cursor: string | null = null): Promise>>> { + const url = new URL(`${BASE_URL}/tools`); + + // set params + url.searchParams.set("toolkit_slug", toolkitSlug); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + + // fetch + return composioApiCall(ZListResponse(ZTool), url.toString()); +} + +export async function listAuthConfigs(toolkitSlug: string, cursor: string | null = null, managedOnly: boolean = false): Promise>>> { + const url = new URL(`${BASE_URL}/auth_configs`); + url.searchParams.set("toolkit_slug", toolkitSlug); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + if (managedOnly) { + url.searchParams.set("is_composio_managed", "true"); + } + + // fetch + return composioApiCall(ZListResponse(ZAuthConfig), url.toString()); +} + +export async function createAuthConfig(request: z.infer): Promise> { + const url = new URL(`${BASE_URL}/auth_configs`); + return composioApiCall(ZCreateAuthConfigResponse, url.toString(), { + method: 'POST', + body: JSON.stringify(request), + }); +} + +export async function getAuthConfig(authConfigId: string): Promise> { + const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`); + return composioApiCall(ZAuthConfig, url.toString()); +} + +export async function deleteAuthConfig(authConfigId: string): Promise> { + const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`); + return composioApiCall(ZDeleteOperationResponse, url.toString(), { + method: 'DELETE', + }); +} + +// export async function createComposioManagedOauth2AuthConfig(toolkitSlug: string): Promise> { +// const response = await createAuthConfig({ +// toolkit: { +// slug: toolkitSlug, +// }, +// auth_config: { +// type: 'use_composio_managed_auth', +// }, +// }); +// return response.auth_config; +// } + +// export async function autocreateOauth2Integration(toolkitSlug: string): Promise> { +// // fetch toolkit +// const toolkit = await getToolkit(toolkitSlug); + +// // ensure oauth2 is supported +// if (!toolkit.auth_config_details?.some(config => config.mode === 'OAUTH2')) { +// throw new Error(`OAuth2 is not supported for toolkit ${toolkitSlug}`); +// } + +// // fetch existing auth configs +// const authConfigs = await fetchAuthConfigs(toolkitSlug); + +// // find a valid oauth2 config +// const oauth2AuthConfig = authConfigs.items.find(config => config.auth_scheme === 'OAUTH2'); + +// // if valid auth config, return it +// if (oauth2AuthConfig) { +// return oauth2AuthConfig; +// } + +// // check if composio managed oauth2 is supported +// if (toolkit.composio_managed_auth_schemes.includes('OAUTH2')) { +// return await createComposioManagedOauth2AuthConfig(toolkitSlug); +// } + +// // else return error +// return { +// error: 'CUSTOM_OAUTH2_CONFIG_REQUIRED', +// }; +// } + +export async function createConnectedAccount(request: z.infer): Promise> { + const url = new URL(`${BASE_URL}/connected_accounts`); + return composioApiCall(ZCreateConnectedAccountResponse, url.toString(), { + method: 'POST', + body: JSON.stringify(request), + }); +} + +// export async function createOauth2ConnectedAccount(toolkitSlug: string, userId: string, callbackUrl: string): Promise> { +// // fetch auth config +// const authConfig = await autocreateOauth2Integration(toolkitSlug); + +// // if error, return error +// if ('error' in authConfig) { +// return authConfig; +// } + +// // create connected account +// return await createConnectedAccount({ +// auth_config: { +// id: authConfig.id, +// }, +// connection: { +// user_id: userId, +// callback_url: callbackUrl, +// }, +// }); +// } + +export async function getConnectedAccount(connectedAccountId: string): Promise> { + const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`); + return await composioApiCall(ZConnectedAccount, url.toString()); +} + +export async function deleteConnectedAccount(connectedAccountId: string): Promise> { + const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`); + return await composioApiCall(ZDeleteOperationResponse, url.toString(), { + method: 'DELETE', + }); +} \ No newline at end of file diff --git a/apps/rowboat/app/lib/feature_flags.ts b/apps/rowboat/app/lib/feature_flags.ts index 9c45c8ae..3fb5b0dd 100644 --- a/apps/rowboat/app/lib/feature_flags.ts +++ b/apps/rowboat/app/lib/feature_flags.ts @@ -6,6 +6,7 @@ export const USE_AUTH = process.env.USE_AUTH === 'true'; export const USE_RAG_S3_UPLOADS = process.env.USE_RAG_S3_UPLOADS === 'true'; export const USE_GEMINI_FILE_PARSING = process.env.USE_GEMINI_FILE_PARSING === 'true'; export const USE_BILLING = process.env.USE_BILLING === 'true'; +export const USE_COMPOSIO_TOOLS = process.env.USE_COMPOSIO_TOOLS === 'true'; // Hardcoded flags export const USE_MULTIPLE_PROJECTS = true; diff --git a/apps/rowboat/app/lib/project_tools.ts b/apps/rowboat/app/lib/project_tools.ts index 8d40cdc4..a65ba535 100644 --- a/apps/rowboat/app/lib/project_tools.ts +++ b/apps/rowboat/app/lib/project_tools.ts @@ -2,50 +2,59 @@ import { z } from "zod"; import { projectsCollection } from "./mongodb"; import { WorkflowTool } from "./types/workflow_types"; -export async function fetchProjectMcpTools(projectId: string): Promise[]> { - // Get project's MCP servers and their tools - const project = await projectsCollection.findOne({ _id: projectId }); - if (!project?.mcpServers) return []; +export async function collectProjectTools(projectId: string): Promise[]> { + const tools: z.infer[] = []; - console.log('[MCP] Getting tools from project:', { - serverCount: project.mcpServers.length, - servers: project.mcpServers.map(s => ({ - name: s.name, - isReady: s.isReady, - toolCount: s.tools.length, - tools: s.tools.map(t => ({ - name: t.name, - hasParams: !!t.parameters, - paramCount: t.parameters ? Object.keys(t.parameters.properties).length : 0, - required: t.parameters?.required || [] - })) - })) - }); + // Get project data + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project) { + throw new Error(`Project ${projectId} not found`); + } // Convert MCP tools to workflow tools format, but only from ready servers - const mcpTools = project.mcpServers - .filter(server => server.isReady) // Only include tools from ready servers - .flatMap(server => { - return server.tools.map(tool => ({ - name: tool.name, + if (project.mcpServers) { + for (const server of project.mcpServers) { + if (server.isReady) { + for (const tool of server.tools) { + tools.push({ + name: tool.name, + description: tool.description || "", + parameters: { + type: 'object' as const, + properties: tool.parameters?.properties || {}, + required: tool.parameters?.required || [] + }, + isMcp: true, + mcpServerName: server.name, + mcpServerURL: server.serverUrl, + }); + } + } + } + } + + // Add Composio tools + if (project.composioSelectedTools) { + for (const tool of project.composioSelectedTools) { + tools.push({ + name: tool.slug, description: tool.description || "", parameters: { type: 'object' as const, - properties: tool.parameters?.properties || {}, - required: tool.parameters?.required || [] + properties: tool.input_parameters?.properties || {}, + required: tool.input_parameters?.required || [] }, - isMcp: true, - mcpServerName: server.name, - mcpServerURL: server.serverUrl, - })); - }); + isComposio: true, + composioData: { + slug: tool.slug, + noAuth: tool.no_auth, + toolkitName: tool.toolkit.name, + toolkitSlug: tool.toolkit.slug, + logo: tool.toolkit.logo, + }, + }); + } + } - console.log('[MCP] Converted tools from ready servers:', mcpTools.map(t => ({ - name: t.name, - hasParams: !!t.parameters, - paramCount: t.parameters ? Object.keys(t.parameters.properties).length : 0, - required: t.parameters?.required || [] - }))); - - return mcpTools; + return tools; } diff --git a/apps/rowboat/app/lib/types/project_types.ts b/apps/rowboat/app/lib/types/project_types.ts index e5084107..54c46984 100644 --- a/apps/rowboat/app/lib/types/project_types.ts +++ b/apps/rowboat/app/lib/types/project_types.ts @@ -1,6 +1,19 @@ import { z } from "zod"; import { MCPServer } from "./types"; import { WorkflowTool } from "./workflow_types"; +import { ZTool } from "../composio/composio"; + +export const ComposioConnectedAccount = z.object({ + id: z.string(), + authConfigId: z.string(), + status: z.enum([ + 'INITIATED', + 'ACTIVE', + 'FAILED', + ]), + createdAt: z.string().datetime(), + lastUpdatedAt: z.string().datetime(), +}); export const Project = z.object({ _id: z.string().uuid(), @@ -15,6 +28,8 @@ export const Project = z.object({ nextWorkflowNumber: z.number().optional(), testRunCounter: z.number().default(0), mcpServers: z.array(MCPServer).optional(), + composioConnectedAccounts: z.record(z.string(), ComposioConnectedAccount).optional(), + composioSelectedTools: z.array(ZTool).optional(), }); export const ProjectMember = z.object({ @@ -38,32 +53,11 @@ export function mergeProjectTools( // Filter out any existing MCP tools from workflow tools const nonMcpTools = workflowTools.filter(t => !t.isMcp); - // Merge with MCP tools + // Merge with project tools const merged = [ ...nonMcpTools, - ...projectTools.map(tool => ({ - ...tool, - isMcp: true as const, // Ensure isMcp is set - parameters: { - type: 'object' as const, - properties: tool.parameters?.properties || {}, - required: tool.parameters?.required || [] - } - })) + ...projectTools ]; - console.log('[mergeMcpTools] Merged tools:', { - totalCount: merged.length, - nonMcpCount: nonMcpTools.length, - mcpCount: projectTools.length, - tools: merged.map(t => ({ - name: t.name, - isMcp: t.isMcp, - hasParams: !!t.parameters, - paramCount: t.parameters ? Object.keys(t.parameters.properties).length : 0, - parameters: t.parameters - })) - }); - return merged; } diff --git a/apps/rowboat/app/lib/types/workflow_types.ts b/apps/rowboat/app/lib/types/workflow_types.ts index bfb0d4dd..49172d9d 100644 --- a/apps/rowboat/app/lib/types/workflow_types.ts +++ b/apps/rowboat/app/lib/types/workflow_types.ts @@ -51,6 +51,14 @@ export const WorkflowTool = z.object({ isLibrary: z.boolean().default(false).optional(), mcpServerName: z.string().optional(), mcpServerURL: z.string().optional(), + isComposio: z.boolean().optional(), // whether this is a Composio tool + composioData: z.object({ + slug: z.string(), // the slug for the Composio tool e.g. "GITHUB_CREATE_AN_ISSUE" + noAuth: z.boolean(), // whether the tool requires no authentication + toolkitName: z.string(), // the name for the Composio toolkit e.g. "GITHUB" + toolkitSlug: z.string(), // the slug for the Composio toolkit e.g. "GITHUB" + logo: z.string(), // the logo for the Composio tool + }).optional(), // the data for the Composio tool, if it is a Composio tool }); export const Workflow = z.object({ name: z.string().optional(), diff --git a/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx b/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx index ecee43e2..0d7ac401 100644 --- a/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx +++ b/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx @@ -170,7 +170,7 @@ export function ToolConfig({ }); const [selectedParams, setSelectedParams] = useState(new Set([])); - const isReadOnly = tool.isMcp || tool.isLibrary; + const isReadOnly = tool.isMcp || tool.isLibrary || tool.isComposio; const [nameError, setNameError] = useState(null); // Log when parameters are being rendered diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx new file mode 100644 index 00000000..dfc4f57e --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Info, RefreshCw, Search } from 'lucide-react'; +import clsx from 'clsx'; +import { listToolkits, listTools, updateComposioSelectedTools } from '@/app/actions/composio_actions'; +import { getProjectConfig } from '@/app/actions/project_actions'; +import { z } from 'zod'; +import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio'; +import { Project } from '@/app/lib/types/project_types'; +import { ComposioToolsPanel } from './ComposioToolsPanel'; +import { ToolkitCard } from './ToolkitCard'; + +type ToolkitType = z.infer; +type ToolkitListResponse = z.infer>>; +type ProjectType = z.infer; + +export function Composio() { + const params = useParams(); + const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0]; + if (!projectId) throw new Error('Project ID is required'); + + const [toolkits, setToolkits] = useState([]); + const [projectConfig, setProjectConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedToolkit, setSelectedToolkit] = useState(null); + const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false); + const [savingTools, setSavingTools] = useState(false); + + const loadProjectConfig = useCallback(async () => { + try { + const config = await getProjectConfig(projectId); + setProjectConfig(config); + } catch (err: any) { + console.error('Error fetching project config:', err); + setError('Unable to load project configuration.'); + } + }, [projectId]); + + const loadAllToolkits = useCallback(async () => { + let cursor: string | null = null; + let allToolkits: ToolkitType[] = []; + + try { + setLoading(true); + + do { + const response: ToolkitListResponse = await listToolkits(projectId, cursor); + allToolkits = [...allToolkits, ...response.items]; + cursor = response.next_cursor; + } while (cursor !== null); + + // // Only show those toolkits that + // // - either do not require authentication, OR + // // - have oauth2 managed by Composio + // const filteredToolkits = allToolkits.filter(toolkit => { + // const noAuth = toolkit.no_auth; + // const hasOAuth2 = toolkit.auth_schemes.includes('OAUTH2'); + // const hasComposioManagedOAuth2 = toolkit.composio_managed_auth_schemes.includes('OAUTH2'); + // return noAuth || hasOAuth2; + // }); + + setToolkits(allToolkits); + setError(null); + } catch (err: any) { + setError('Unable to load all Composio toolkits. Please check your connection and try again.'); + console.error('Error fetching all toolkits:', err); + setToolkits([]); + } finally { + setLoading(false); + } + }, [projectId]); + + const handleManageTools = useCallback((toolkit: ToolkitType) => { + setSelectedToolkit(toolkit); + setIsToolsPanelOpen(true); + }, []); + + const handleCloseToolsPanel = useCallback(() => { + setSelectedToolkit(null); + setIsToolsPanelOpen(false); + }, []); + + const handleProjectConfigUpdate = useCallback(() => { + loadProjectConfig(); + }, [loadProjectConfig]); + + const handleUpdateToolsSelection = useCallback(async (selectedToolObjects: z.infer[]) => { + if (!projectId) return; + + setSavingTools(true); + try { + // Get existing selected tools from project config + const existingSelectedTools = projectConfig?.composioSelectedTools || []; + + // Create a map of existing tools by slug for easy lookup + const existingToolsMap = new Map(existingSelectedTools.map(tool => [tool.slug, tool])); + + // Add or update the new selections + for (const tool of selectedToolObjects) { + existingToolsMap.set(tool.slug, tool); + } + + // Convert back to array + const mergedSelectedTools = Array.from(existingToolsMap.values()); + + await updateComposioSelectedTools(projectId, mergedSelectedTools); + + // Refresh project config to get updated data + await loadProjectConfig(); + } catch (error) { + console.error('Error saving tool selection:', error); + } finally { + setSavingTools(false); + } + }, [projectId, projectConfig, loadProjectConfig]); + + const handleRemoveToolkitTools = useCallback(async (toolkitSlug: string) => { + if (!projectId) return; + + setSavingTools(true); + try { + // Get existing selected tools from project config + const existingSelectedTools = projectConfig?.composioSelectedTools || []; + + // Filter out all tools from the specified toolkit + const filteredSelectedTools = existingSelectedTools.filter(tool => + tool.toolkit.slug !== toolkitSlug + ); + + await updateComposioSelectedTools(projectId, filteredSelectedTools); + + // Refresh project config to get updated data + await loadProjectConfig(); + } catch (error) { + console.error('Error removing toolkit tools:', error); + } finally { + setSavingTools(false); + } + }, [projectId, projectConfig, loadProjectConfig]); + + useEffect(() => { + loadProjectConfig(); + }, [loadProjectConfig]); + + useEffect(() => { + loadAllToolkits(); + }, [loadAllToolkits]); + + const filteredToolkits = toolkits.filter(toolkit => { + const searchLower = searchQuery.toLowerCase(); + return ( + toolkit.name.toLowerCase().includes(searchLower) || + toolkit.meta.description.toLowerCase().includes(searchLower) || + toolkit.slug.toLowerCase().includes(searchLower) + ); + }); + + if (loading) { + return ( +
+
+

Loading Composio toolkits...

+
+ ); + } + + if (error) { + return ( +
+

+ {error} +

+ +
+ ); + } + + return ( +
+
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+ setSearchQuery(e.target.value)} + className="w-full pl-8 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md + bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 + placeholder-gray-400 dark:placeholder-gray-500 + focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 + hover:border-gray-300 dark:hover:border-gray-600 transition-colors" + /> +
+
+ {filteredToolkits.length} {filteredToolkits.length === 1 ? 'toolkit' : 'toolkits'} +
+
+
+ +
+
+ +
+ {filteredToolkits.map((toolkit) => { + const isConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE'; + const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id; + + return ( + handleManageTools(toolkit)} + onProjectConfigUpdate={handleProjectConfigUpdate} + onRemoveToolkitTools={handleRemoveToolkitTools} + /> + ); + })} +
+ + {filteredToolkits.length === 0 && !loading && ( +
+

+ {searchQuery ? 'No toolkits found matching your search.' : 'No toolkits available.'} +

+
+ )} + + {/* Tools Panel */} + +
+ ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx new file mode 100644 index 00000000..4dd25e87 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { PictureImg } from '@/components/ui/picture-img'; +import { Checkbox } from '@heroui/react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { listTools } from '@/app/actions/composio_actions'; +import { z } from 'zod'; +import { ZTool, ZListResponse } from '@/app/lib/composio/composio'; +import { SlidePanel } from '@/components/ui/slide-panel'; +import { Project } from '@/app/lib/types/project_types'; + +type ToolType = z.infer; +type ToolListResponse = z.infer>>; +type ProjectType = z.infer; + +interface ComposioToolsPanelProps { + toolkit: { + slug: string; + name: string; + meta: { + logo: string; + }; + no_auth?: boolean; + } | null; + isOpen: boolean; + onClose: () => void; + projectConfig: ProjectType | null; + onUpdateToolsSelection: (selectedToolObjects: ToolType[]) => void; + isSaving: boolean; +} + +export function ComposioToolsPanel({ + toolkit, + isOpen, + onClose, + projectConfig, + onUpdateToolsSelection, + isSaving +}: ComposioToolsPanelProps) { + const params = useParams(); + const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0]; + if (!projectId) throw new Error('Project ID is required'); + + const [tools, setTools] = useState([]); + const [toolsLoading, setToolsLoading] = useState(false); + const [currentCursor, setCurrentCursor] = useState(null); + const [nextCursor, setNextCursor] = useState(null); + const [cursorHistory, setCursorHistory] = useState([]); + const [selectedTools, setSelectedTools] = useState>(new Set()); + const [hasChanges, setHasChanges] = useState(false); + + const loadToolsForToolkit = useCallback(async (toolkitSlug: string, cursor: string | null = null) => { + try { + setToolsLoading(true); + + const response: ToolListResponse = await listTools(projectId, toolkitSlug, cursor); + + setTools(response.items); + setNextCursor(response.next_cursor); + + if (cursor === null) { + // First page - reset pagination state + setCurrentCursor(null); + setCursorHistory([]); + } + } catch (err: any) { + console.error('Error fetching tools:', err); + setTools([]); + } finally { + setToolsLoading(false); + } + }, [projectId]); + + const handleNextPage = useCallback(async () => { + if (!nextCursor || !toolkit) return; + + // Add current cursor to history + setCursorHistory(prev => [...prev, currentCursor || '']); + setCurrentCursor(nextCursor); + + await loadToolsForToolkit(toolkit.slug, nextCursor); + }, [nextCursor, toolkit, currentCursor, loadToolsForToolkit]); + + const handlePreviousPage = useCallback(async () => { + if (cursorHistory.length === 0 || !toolkit) return; + + // Get the previous cursor from history + const previousCursor = cursorHistory[cursorHistory.length - 1]; + const newHistory = cursorHistory.slice(0, -1); + + setCursorHistory(newHistory); + setCurrentCursor(previousCursor); + + await loadToolsForToolkit(toolkit.slug, previousCursor); + }, [cursorHistory, toolkit, loadToolsForToolkit]); + + const handleToolSelectionChange = useCallback((toolSlug: string, selected: boolean) => { + setSelectedTools(prev => { + const next = new Set(prev); + if (selected) { + next.add(toolSlug); + } else { + next.delete(toolSlug); + } + setHasChanges(true); + return next; + }); + }, []); + + const handleSaveTools = useCallback(async () => { + // Convert selected tool slugs to actual tool objects + const selectedToolObjects = tools.filter(tool => selectedTools.has(tool.slug)); + await onUpdateToolsSelection(selectedToolObjects); + setHasChanges(false); + }, [onUpdateToolsSelection, selectedTools, tools]); + + const handleClose = useCallback(() => { + setTools([]); + setSelectedTools(new Set()); + setHasChanges(false); + if (hasChanges) { + if (window.confirm('You have unsaved changes. Are you sure you want to close?')) { + onClose(); + } + } else { + onClose(); + } + }, [onClose, hasChanges]); + + // Initialize selected tools from project config when opening the panel + useEffect(() => { + if (toolkit && isOpen && projectConfig?.composioSelectedTools) { + const toolSlugs = new Set(projectConfig.composioSelectedTools.map(tool => tool.slug)); + setSelectedTools(toolSlugs); + setHasChanges(false); + } + }, [toolkit, isOpen, projectConfig]); + + useEffect(() => { + if (toolkit && isOpen) { + loadToolsForToolkit(toolkit.slug, null); + } + }, [toolkit, isOpen, loadToolsForToolkit]); + + if (!toolkit) return null; + + // Check if the toolkit is connected (has an active connected account) or doesn't require auth + const isToolkitConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE'; + + return ( + + {toolkit.meta.logo && ( + + )} + {toolkit.name} +
+ } + > +
+ {/* Header */} +
+
+

Available Tools

+
+ {!isToolkitConnected && !toolkit.no_auth && ( +
+ Toolkit not connected +
+ )} + {hasChanges && ( + + )} +
+
+
+ + {/* Scrollable Tools List */} +
+ {toolsLoading ? ( +
+
+

Loading tools...

+
+ ) : ( +
+ {tools.map((tool) => ( +
+
+ handleToolSelectionChange(tool.slug, selected)} + size="sm" + isDisabled={!isToolkitConnected} + /> +
+

+ {tool.name} +

+

+ {tool.description} +

+
+
+
+ ))} +
+ )} +
+ + {/* Fixed Pagination Controls */} +
+
+
+ + +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitAuthModal.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitAuthModal.tsx new file mode 100644 index 00000000..44c3a8e0 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitAuthModal.tsx @@ -0,0 +1,518 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Spinner, Button as HeroButton, Input } from "@heroui/react"; +import { PictureImg } from '@/components/ui/picture-img'; +import { Wrench, Shield, Key, Globe, ArrowLeft } from "lucide-react"; +import { getToolkit, createComposioManagedOauth2ConnectedAccount, syncConnectedAccount, listToolkits, createCustomConnectedAccount } from '@/app/actions/composio_actions'; +import { z } from 'zod'; +import { ZGetToolkitResponse, ZToolkit, ZComposioField, ZAuthScheme } from '@/app/lib/composio/composio'; + +interface ToolkitAuthModalProps { + isOpen: boolean; + onClose: () => void; + toolkitSlug: string; + projectId: string; + onComplete: () => void; +} + +export function ToolkitAuthModal({ + isOpen, + onClose, + toolkitSlug, + projectId, + onComplete +}: ToolkitAuthModalProps) { + const [toolkit, setToolkit] = useState | null>(null); + const [toolkitDetails, setToolkitDetails] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + + // Form state + const [showForm, setShowForm] = useState(false); + const [selectedAuthScheme, setSelectedAuthScheme] = useState | null>(null); + const [formData, setFormData] = useState>({}); + + // Fetch toolkit details when modal opens + useEffect(() => { + if (isOpen && toolkitSlug) { + setLoading(true); + setError(null); + + // Fetch both toolkit auth details and full toolkit info + Promise.all([ + getToolkit(projectId, toolkitSlug), + listToolkits(projectId).then(response => + response.items.find(t => t.slug === toolkitSlug) || null + ) + ]) + .then(([authDetails, fullDetails]) => { + setToolkit(authDetails); + setToolkitDetails(fullDetails); + }) + .catch(err => { + console.error('Failed to fetch toolkit:', err); + setError('Failed to load toolkit details'); + }) + .finally(() => setLoading(false)); + } + }, [isOpen, toolkitSlug, projectId]); + + // Reset form state when modal closes + useEffect(() => { + if (!isOpen) { + setShowForm(false); + setSelectedAuthScheme(null); + setFormData({}); + setError(null); + } + }, [isOpen]); + + const handleOAuthCompletion = useCallback(async (connectedAccountId: string) => { + try { + // Sync the connected account to get the latest status + await syncConnectedAccount(projectId, toolkitSlug, connectedAccountId); + + // Call completion callback + onComplete(); + onClose(); + } catch (error) { + console.error('Error syncing connected account after OAuth:', error); + setError('Authentication completed but failed to sync status. Please refresh and try again.'); + } + }, [projectId, toolkitSlug, onComplete, onClose]); + + const handleComposioOAuth2 = useCallback(async () => { + setError(null); + setProcessing(true); + + try { + // Start OAuth flow + const returnUrl = `${window.location.origin}/composio/oauth2/callback`; + const response = await createComposioManagedOauth2ConnectedAccount(projectId, toolkitSlug, returnUrl); + console.log('OAuth response:', JSON.stringify(response, null, 2)); + + // if error, set error + if ('error' in response) { + if (response.error === 'CUSTOM_OAUTH2_CONFIG_REQUIRED') { + setError('Please set up a custom OAuth2 configuration for this toolkit in the Composio dashboard'); + } else { + setError('Failed to connect to toolkit'); + } + return; + } + + // Open OAuth window + const authWindow = window.open( + response.connectionData.val.redirectUrl as string, + '_blank', + 'width=600,height=700' + ); + + if (authWindow) { + // Use postMessage since we control the callback URL + const handleMessage = (event: MessageEvent) => { + // Only accept messages from our own origin + if (event.origin !== window.location.origin) { + return; + } + + // Check if this is an OAuth completion message + if (event.data && event.data.type === 'OAUTH_COMPLETE') { + window.removeEventListener('message', handleMessage); + clearInterval(checkInterval); + + if (event.data.success) { + // Handle successful OAuth completion + handleOAuthCompletion(response.id); + } else { + // Handle OAuth error + const errorMessage = event.data.errorDescription || event.data.error || 'OAuth authentication failed'; + setError(errorMessage); + } + } + }; + + // Listen for postMessage from our callback page + window.addEventListener('message', handleMessage); + + // Minimal fallback: check if window closes without message + const checkInterval = setInterval(() => { + if (authWindow.closed) { + clearInterval(checkInterval); + window.removeEventListener('message', handleMessage); + + // If we didn't get a postMessage, still try to sync + // (in case the message was missed for some reason) + handleOAuthCompletion(response.id); + } + }, 1000); // Check less frequently since we expect postMessage + } else { + window.alert('Failed to open authentication window. Please check your popup blocker settings.'); + setError('Failed to open authentication window'); + } + } catch (err: any) { + console.error('OAuth flow failed:', err); + const errorMessage = err.message || 'Failed to connect to toolkit'; + setError(errorMessage); + } finally { + setProcessing(false); + } + }, [projectId, toolkitSlug, handleOAuthCompletion]); + + const handleCustomAuth = useCallback((authScheme: z.infer) => { + setSelectedAuthScheme(authScheme); + + // Initialize form data with default values + const authConfig = toolkit?.auth_config_details?.find(config => config.mode === authScheme); + + if (authConfig) { + const initialData: Record = {}; + + // Try connected_account_initiation first, fallback to auth_config_creation + const requiredFields = authConfig.fields.connected_account_initiation.required.length > 0 + ? authConfig.fields.connected_account_initiation.required + : authConfig.fields.auth_config_creation.required; + + const optionalFields = authConfig.fields.connected_account_initiation.optional.length > 0 + ? authConfig.fields.connected_account_initiation.optional + : authConfig.fields.auth_config_creation.optional; + + // Add defaults for required fields + requiredFields.forEach(field => { + if (field.default) { + initialData[field.name] = field.default; + } + }); + + // Add defaults for optional fields + optionalFields.forEach(field => { + if (field.default) { + initialData[field.name] = field.default; + } + }); + + setFormData(initialData); + } + + setShowForm(true); + }, [toolkit]); + + const handleFormSubmit = useCallback(async () => { + if (!selectedAuthScheme || !toolkit) return; + + setError(null); + setProcessing(true); + + try { + const callbackUrl = `${window.location.origin}/composio/oauth2/callback`; + const response = await createCustomConnectedAccount(projectId, { + toolkitSlug: toolkit.slug, + authConfig: { + authScheme: selectedAuthScheme, + credentials: formData, + }, + callbackUrl, + }); + + console.log('Custom auth response:', JSON.stringify(response, null, 2)); + + // Check if we need to open a popup window (OAuth2 flow) + if ('connectionData' in response && + response.connectionData.val && + 'redirectUrl' in response.connectionData.val && + response.connectionData.val.redirectUrl) { + + // Open OAuth window for custom OAuth2 + const authWindow = window.open( + response.connectionData.val.redirectUrl as string, + '_blank', + 'width=600,height=700' + ); + + if (authWindow) { + // Use the same postMessage logic as Composio OAuth2 + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) { + return; + } + + if (event.data && event.data.type === 'OAUTH_COMPLETE') { + window.removeEventListener('message', handleMessage); + clearInterval(checkInterval); + + if (event.data.success) { + handleOAuthCompletion(response.id); + } else { + const errorMessage = event.data.errorDescription || event.data.error || 'OAuth authentication failed'; + setError(errorMessage); + } + } + }; + + window.addEventListener('message', handleMessage); + + const checkInterval = setInterval(() => { + if (authWindow.closed) { + clearInterval(checkInterval); + window.removeEventListener('message', handleMessage); + handleOAuthCompletion(response.id); + } + }, 1000); + } else { + window.alert('Failed to open authentication window. Please check your popup blocker settings.'); + setError('Failed to open authentication window'); + } + } else { + // No redirect needed, just sync and complete + await syncConnectedAccount(projectId, toolkitSlug, response.id); + onComplete(); + onClose(); + } + } catch (err: any) { + console.error('Custom auth failed:', err); + const errorMessage = err.message || 'Failed to authenticate with toolkit'; + setError(errorMessage); + } finally { + setProcessing(false); + } + }, [selectedAuthScheme, toolkit, projectId, formData, handleOAuthCompletion, onComplete, onClose, toolkitSlug]); + + const handleBackToOptions = useCallback(() => { + setShowForm(false); + setSelectedAuthScheme(null); + setFormData({}); + setError(null); + }, []); + + const getAuthMethodIcon = (authScheme: string) => { + switch (authScheme) { + case 'OAUTH2': + return ; + case 'API_KEY': + return ; + case 'BEARER_TOKEN': + return ; + default: + return ; + } + }; + + const getAuthMethodName = (authScheme: string) => { + switch (authScheme) { + case 'OAUTH2': + return 'OAuth2'; + case 'API_KEY': + return 'API Key'; + case 'BEARER_TOKEN': + return 'Bearer Token'; + case 'BASIC': + return 'Basic Auth'; + default: + return authScheme.toLowerCase().replace('_', ' '); + } + }; + + return ( + + + + {showForm && ( + + + + )} + {toolkitDetails?.meta?.logo ? ( + + ) : ( + + )} + + {showForm + ? `Configure ${getAuthMethodName(selectedAuthScheme || '')}` + : `Connect to ${toolkitSlug}` + } + + + + {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : toolkit ? ( + showForm ? ( + // Form view +
+
+ Enter your credentials for {getAuthMethodName(selectedAuthScheme || '')} authentication: +
+ + {(() => { + const authConfig = toolkit.auth_config_details?.find(config => config.mode === selectedAuthScheme); + + if (!authConfig) { + return
No configuration found for {selectedAuthScheme}
; + } + + // Try connected_account_initiation first, fallback to auth_config_creation + const allFields = [ + ...authConfig.fields.connected_account_initiation.required.map(field => ({ ...field, required: true })), + ...authConfig.fields.connected_account_initiation.optional.map(field => ({ ...field, required: false })) + ]; + + // If no fields in connected_account_initiation, try auth_config_creation + if (allFields.length === 0) { + allFields.push( + ...authConfig.fields.auth_config_creation.required.map(field => ({ ...field, required: true })), + ...authConfig.fields.auth_config_creation.optional.map(field => ({ ...field, required: false })) + ); + } + + return ( +
+ {allFields.map(field => ( + setFormData(prev => ({ ...prev, [field.name]: value }))} + isRequired={field.required} + type={field.type === 'password' ? 'password' : 'text'} + variant="bordered" + description={field.description} + required={field.required} + /> + ))} +
+ ); + })()} +
+ ) : ( + // Auth options view +
+
+ Choose how you'd like to authenticate with this toolkit: +
+ +
+ {/* OAuth2 Composio Managed */} + {toolkit.composio_managed_auth_schemes.includes('OAUTH2') && ( + +
+ +
+
+
Connect using OAuth2
+
+ Secure authentication managed by Composio +
+
+ {processing && } +
+ )} + + {/* Custom OAuth2 - always show if OAuth2 is supported */} + {(toolkit.composio_managed_auth_schemes.includes('OAUTH2') || + toolkit.auth_config_details?.some(config => config.mode === 'OAUTH2')) && ( + handleCustomAuth('OAUTH2')} + isDisabled={processing} + size="lg" + > +
+ +
+
+
Connect using custom OAuth2 app
+
+ Use your own OAuth2 configuration +
+
+
+ )} + + {/* Other auth schemes (excluding OAuth2 since it's shown above) */} + {toolkit.auth_config_details?.filter(config => config.mode !== 'OAUTH2').map(config => ( + handleCustomAuth(config.mode)} + isDisabled={processing} + size="lg" + > +
+ {getAuthMethodIcon(config.mode)} +
+
+
Connect using {getAuthMethodName(config.mode)}
+
+ Enter your credentials +
+
+
+ ))} +
+
+ ) + ) : null} +
+ + {showForm ? ( + <> + + Back + + + {processing ? 'Connecting...' : 'Connect'} + + + ) : ( + + Cancel + + )} + +
+
+ ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx new file mode 100644 index 00000000..31612e3f --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { PictureImg } from '@/components/ui/picture-img'; +import { Wrench } from 'lucide-react'; +import clsx from 'clsx'; +import { Spinner } from '@heroui/react'; +import { deleteConnectedAccount } from '@/app/actions/composio_actions'; +import { z } from 'zod'; +import { ZToolkit } from '@/app/lib/composio/composio'; +import { Project } from '@/app/lib/types/project_types'; +import { ToolkitAuthModal } from './ToolkitAuthModal'; + +type ToolkitType = z.infer; +type ProjectType = z.infer; + +const toolkitCardStyles = { + base: clsx( + "group p-6 rounded-xl transition-all duration-200", + "bg-white dark:bg-gray-900 shadow-sm dark:shadow-none", + "border-2 border-gray-200/80 dark:border-gray-700/80", + "hover:shadow-md dark:hover:shadow-none", + "hover:border-blue-200 dark:hover:border-blue-900", + "min-h-[280px] flex flex-col" + ), +}; + +interface ToolkitCardProps { + toolkit: ToolkitType; + projectId: string; + isConnected: boolean; + connectedAccountId?: string; + projectConfig: ProjectType | null; + onManageTools: () => void; + onProjectConfigUpdate: () => void; + onRemoveToolkitTools: (toolkitSlug: string) => void; +} + +export function ToolkitCard({ + toolkit, + projectId, + isConnected, + connectedAccountId, + projectConfig, + onManageTools, + onProjectConfigUpdate, + onRemoveToolkitTools +}: ToolkitCardProps) { + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [showAuthModal, setShowAuthModal] = useState(false); + + const handleToggleConnection = useCallback(async () => { + const newState = !isConnected; + + // Clear any previous error when starting a new operation + setError(null); + + if (newState) { + // Show authentication modal + setShowAuthModal(true); + } else { + // Disconnect - remove the connected account + setIsProcessing(true); + try { + if (connectedAccountId) { + await deleteConnectedAccount(projectId, toolkit.slug, connectedAccountId); + onProjectConfigUpdate(); + onRemoveToolkitTools(toolkit.slug); + } else { + // Fallback: just refresh the project config + onProjectConfigUpdate(); + } + } catch (err: any) { + console.error('Disconnect failed:', err); + const errorMessage = err.message || 'Failed to disconnect toolkit'; + setError(errorMessage); + } finally { + setIsProcessing(false); + } + } + }, [projectId, toolkit.slug, isConnected, connectedAccountId, onProjectConfigUpdate, onRemoveToolkitTools]); + + const handleAuthComplete = useCallback(() => { + // Update project config when authentication completes + onProjectConfigUpdate(); + }, [onProjectConfigUpdate]); + + // Calculate selected tools count for this toolkit + const selectedToolsCount = projectConfig?.composioSelectedTools?.filter(tool => + tool.toolkit.slug === toolkit.slug + ).length || 0; + + return ( +
+
+
+
+ {toolkit.meta.logo && ( + + )} +
+

+ {toolkit.name} +

+
+ + {toolkit.meta.tools_count} tools + + {selectedToolsCount > 0 && ( + + {selectedToolsCount} selected + + )} + {toolkit.no_auth && ( + + No Auth + + )} +
+
+
+
+ {toolkit.no_auth ? ( +
+ {}} // No-op for no-auth toolkits + disabled={true} + className={clsx( + "data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600", + "data-[state=unchecked]:bg-emerald-500 dark:data-[state=unchecked]:bg-emerald-600", + "opacity-50 cursor-not-allowed", + "scale-75" + )} + /> + + Always Available + +
+ ) : ( + + )} +
+
+ +
+

+ {toolkit.meta.description} +

+
+ +
+
+
+ ID: {toolkit.slug} +
+
+ {isProcessing && ( +
+ + Processing... +
+ )} + {(isConnected || toolkit.no_auth) && !isProcessing && ( +
+ {toolkit.no_auth ? 'Available' : 'Connected'} +
+ )} + {error && ( +
+ Error: {error} +
+ )} + +
+
+
+
+ + setShowAuthModal(false)} + toolkitSlug={toolkit.slug} + projectId={projectId} + onComplete={handleAuthComplete} + /> +
+ ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx index ade90081..67ba2fbc 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx @@ -4,10 +4,11 @@ import { useState } from 'react'; import { Tabs, Tab } from '@/components/ui/tabs'; import { HostedServers } from './HostedServers'; import { CustomServers } from './CustomServers'; +import { Composio } from './Composio'; import type { Key } from 'react'; -export function ToolsConfig() { - const [activeTab, setActiveTab] = useState('hosted'); +export function ToolsConfig({ useComposioTools }: { useComposioTools: boolean }) { + const [activeTab, setActiveTab] = useState(useComposioTools ? 'composio' : 'hosted'); const handleTabChange = (key: Key) => { setActiveTab(key.toString()); @@ -22,6 +23,13 @@ export function ToolsConfig() { className="w-full" fullWidth > + {useComposioTools && ( + +
+ +
+
+ )} Tools Library diff --git a/apps/rowboat/app/projects/[projectId]/tools/page.tsx b/apps/rowboat/app/projects/[projectId]/tools/page.tsx index 5947eb8b..4a81b752 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/page.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/page.tsx @@ -2,6 +2,7 @@ import { Suspense } from 'react'; import { ToolsConfig } from './components/ToolsConfig'; import { PageHeader } from '@/components/ui/page-header'; import { requireActiveBillingSubscription } from '@/app/lib/billing'; +import { USE_COMPOSIO_TOOLS } from '@/app/lib/feature_flags'; export default async function ToolsPage() { await requireActiveBillingSubscription(); @@ -14,7 +15,7 @@ export default async function ToolsPage() { />
Loading...
}> - + diff --git a/apps/rowboat/app/projects/[projectId]/workflow/app.tsx b/apps/rowboat/app/projects/[projectId]/workflow/app.tsx index d48a3320..fe5abcc2 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/app.tsx @@ -9,7 +9,8 @@ import { WorkflowSelector } from "./workflow_selector"; import { Spinner } from "@heroui/react"; import { cloneWorkflow, createWorkflow, fetchPublishedWorkflowId, fetchWorkflow } from "../../../actions/workflow_actions"; import { listDataSources } from "../../../actions/datasource_actions"; -import { listMcpServers, listProjectMcpTools } from "@/app/actions/mcp_actions"; +import { listMcpServers } from "@/app/actions/mcp_actions"; +import { collectProjectTools } from "@/app/actions/project_actions"; import { getProjectConfig } from "@/app/actions/project_actions"; import { WorkflowTool } from "@/app/lib/types/workflow_types"; import { getEligibleModels } from "@/app/actions/billing_actions"; @@ -51,7 +52,7 @@ export function App({ listDataSources(projectId), listMcpServers(projectId), getProjectConfig(projectId), - listProjectMcpTools(projectId), + collectProjectTools(projectId), getEligibleModels(), ]); diff --git a/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx b/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx index 0dda552b..db6025ee 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx @@ -8,6 +8,7 @@ import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalList import { CSS } from '@dnd-kit/utilities'; import { Panel } from "@/components/common/panel-common"; import { Button } from "@/components/ui/button"; +import { PictureImg } from "@/components/ui/picture-img"; import { clsx } from "clsx"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import { ServerLogo } from '../tools/components/MCPServersCommon'; @@ -204,6 +205,13 @@ const ServerCard = ({ ); }; +type ComposioToolkit = { + slug: string; + name: string; + logo: string; + tools: z.infer[]; +} + export function EntityList({ agents, tools, @@ -234,6 +242,22 @@ export function EntityList({ const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(0); + // collect composio tools + const composioTools: Record = {}; + for (const tool of mergedTools) { + if (tool.isComposio) { + if (!composioTools[tool.composioData?.toolkitSlug || '']) { + composioTools[tool.composioData?.toolkitSlug || ''] = { + name: tool.composioData?.toolkitName || '', + slug: tool.composioData?.toolkitSlug || '', + logo: tool.composioData?.logo || '', + tools: [] + }; + } + composioTools[tool.composioData?.toolkitSlug || ''].tools.push(tool); + } + } + // Panel expansion states const [expandedPanels, setExpandedPanels] = useState({ agents: true, @@ -469,7 +493,7 @@ export function EntityList({ {/* Group tools by server */} {(() => { // Get custom tools (non-MCP tools) - const customTools = mergedTools.filter(tool => !tool.isMcp); + const customTools = mergedTools.filter(tool => !tool.isMcp && !tool.isComposio); // Group MCP tools by server const serverTools = mergedTools.reduce((acc, tool) => { @@ -484,7 +508,19 @@ export function EntityList({ return ( <> - {/* Show MCP server cards first */} + {/* Show composio cards */} + {Object.values(composioTools).map((card) => ( + + ))} + + {/* Show MCP server cards */} {Object.entries(serverTools).map(([serverName, tools]) => ( void; + onDeleteTool: (name: string) => void; + selectedRef: React.RefObject; +} + +const ComposioCard = ({ + card, + selectedEntity, + onSelectTool, + onDeleteTool, + selectedRef, +}: ComposioCardProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + {isExpanded && ( +
+ {card.tools.map((tool, index) => ( + onSelectTool(tool.name)} + disabled={tool.isLibrary} + selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined} + icon={ + card.logo ? ( +
+ +
+ ) : ( + + ) + } + menuContent={ + + } + /> + ))} +
+ )} +
+ ); +}; + // Add SortableItem component for agents const SortableAgentItem = ({ agent, isSelected, onClick, selectedRef, statusLabel, onToggle, onSetMainAgent, onDelete, isStartAgent }: { agent: z.infer; diff --git a/apps/rowboat/components/ui/picture-img.tsx b/apps/rowboat/components/ui/picture-img.tsx new file mode 100644 index 00000000..758416e5 --- /dev/null +++ b/apps/rowboat/components/ui/picture-img.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +interface SourceProps { + srcSet: string; + media?: string; + type?: string; + sizes?: string; +} + +interface PictureImgProps extends React.ImgHTMLAttributes { + src: string; + alt: string; + sources?: SourceProps[]; + fallbackSrc?: string; + onError?: React.ReactEventHandler; +} + +export function PictureImg({ + src, + alt, + sources = [], + fallbackSrc, + onError, + className, + ...imgProps +}: PictureImgProps) { + const handleError: React.ReactEventHandler = (e) => { + if (fallbackSrc && e.currentTarget.src !== fallbackSrc) { + e.currentTarget.src = fallbackSrc; + return; + } + + if (onError) { + onError(e); + } else { + // Default error handling - hide the image + e.currentTarget.style.display = 'none'; + } + }; + + return ( + + {sources.map((source, index) => ( + + ))} + {alt} + + ); +} \ No newline at end of file diff --git a/apps/rowboat/package-lock.json b/apps/rowboat/package-lock.json index 7d60f3b9..fa08f6e3 100644 --- a/apps/rowboat/package-lock.json +++ b/apps/rowboat/package-lock.json @@ -12,6 +12,7 @@ "@auth0/nextjs-auth0": "^4.7.0", "@aws-sdk/client-s3": "^3.743.0", "@aws-sdk/s3-request-presigner": "^3.743.0", + "@composio/core": "^0.1.36-next.7", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -1356,6 +1357,59 @@ "node": ">=6.9.0" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, + "node_modules/@composio/client": { + "version": "0.1.0-alpha.26", + "resolved": "https://registry.npmjs.org/@composio/client/-/client-0.1.0-alpha.26.tgz", + "integrity": "sha512-6cLf8sABDe0tjG4U33GDd3ZS4S3YdVCEpFyi9bt22vanqtNKJk8wNEOFOui9TgI131B45Tt/XtlCeCR+PpxQLQ==", + "license": "Apache-2.0" + }, + "node_modules/@composio/core": { + "version": "0.1.36-next.7", + "resolved": "https://registry.npmjs.org/@composio/core/-/core-0.1.36-next.7.tgz", + "integrity": "sha512-MqwYKBIoCMn9VegS67UO/RkO/C5L7aA0r28K7VbIS4ZwMY/9/hl7UPqat7EZapyhpLFw4OaTHeAjAMcu6dRm1g==", + "license": "ISC", + "dependencies": { + "@composio/client": "0.1.0-alpha.26", + "@composio/json-schema-to-zod": "0.1.10-next.3", + "@types/json-schema": "^7.0.15", + "chalk": "^4.1.2", + "openai": "^4.94.0", + "pusher-js": "^8.4.0", + "semver": "^7.7.2", + "uuid": "^11.1.0", + "zod": "^3.24.2", + "zod-to-json-schema": "^3.24.5" + } + }, + "node_modules/@composio/core/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@composio/json-schema-to-zod": { + "version": "0.1.10-next.3", + "resolved": "https://registry.npmjs.org/@composio/json-schema-to-zod/-/json-schema-to-zod-0.1.10-next.3.tgz", + "integrity": "sha512-kglZdteq+18Tcikb1US7D47VX3A3x8haeE+qFCwpQbBAgp3vG+eMQnZ6WgMkLNroZNrTm5oiAJrSdjerbiAqIA==", + "license": "ISC", + "dependencies": { + "@types/json-schema": "^7.0.15", + "zod": "^3.24.2" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -4331,20 +4385,22 @@ } }, "node_modules/@langchain/core": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.7.tgz", - "integrity": "sha512-6wsnEtw5GlhmBhoLfw/g8Hrp09BNwQwDLXyuv3GyK+ay4/3H3YuhAphqQLO4HNphuZIZKlW9ihSrqdCMvvbvZQ==", + "version": "0.3.61", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.61.tgz", + "integrity": "sha512-4O7fw5SXNSE+uBnathLQrhm3t+7dZGagt/5kt37A+pXw0AkudxEBvveg73sSnpBd9SIz3/Vc7F4k8rCKXGbEDA==", + "license": "MIT", "dependencies": { + "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", - "langsmith": "^0.1.56", + "langsmith": "^0.3.33", "mustache": "^4.2.0", "p-queue": "^6.6.2", "p-retry": "4", "uuid": "^10.0.0", - "zod": "^3.22.4", + "zod": "^3.25.32", "zod-to-json-schema": "^3.22.3" }, "engines": { @@ -7576,6 +7632,12 @@ "@types/unist": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -7661,7 +7723,8 @@ "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" }, "node_modules/@types/styled-system": { "version": "5.1.23", @@ -7696,7 +7759,8 @@ "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==" + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", @@ -8133,7 +8197,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -8687,7 +8750,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -8904,6 +8966,15 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/console-table-printer": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.14.6.tgz", + "integrity": "sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw==", + "license": "MIT", + "dependencies": { + "simple-wcswidth": "^1.0.1" + } + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -10151,7 +10222,8 @@ "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" }, "node_modules/eventsource": { "version": "3.0.5", @@ -10821,7 +10893,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -11853,34 +11924,40 @@ } }, "node_modules/langsmith": { - "version": "0.1.61", - "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.1.61.tgz", - "integrity": "sha512-XQE4KPScwPmdaT0mWDzhNxj9gvqXUR+C7urLA0QFi27XeoQdm17eYpudenn4wxC0gIyUJutQCyuYJpfwlT5JnQ==", + "version": "0.3.37", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.37.tgz", + "integrity": "sha512-aDFM+LbT01gP8hsJNs4QJjmbRNfoifqhpCSpk8j4k/V8wejEgvgATbgj9W9DQsfQFdtfwx+8G48sK5/0PqQisg==", + "license": "MIT", "dependencies": { "@types/uuid": "^10.0.0", - "commander": "^10.0.1", + "chalk": "^4.1.2", + "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "p-retry": "4", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, "openai": { "optional": true } } }, - "node_modules/langsmith/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "engines": { - "node": ">=14" - } - }, "node_modules/langsmith/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -11889,6 +11966,7 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } @@ -14129,9 +14207,10 @@ } }, "node_modules/openai": { - "version": "4.67.2", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.67.2.tgz", - "integrity": "sha512-u4FJFGXgqEHrCYcD5jAD4nHj6JCiicH+/dskQY7qka9R6hOw29R0kOz7GwcA9k2JKcLf86lzAWPtPagPbO8KnQ==", + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -14145,9 +14224,13 @@ "openai": "bin/cli" }, "peerDependencies": { + "ws": "^8.18.0", "zod": "^3.23.8" }, "peerDependenciesMeta": { + "ws": { + "optional": true + }, "zod": { "optional": true } @@ -14200,6 +14283,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", "engines": { "node": ">=4" } @@ -14238,6 +14322,7 @@ "version": "6.6.2", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" @@ -14253,6 +14338,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" @@ -14265,6 +14351,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", "dependencies": { "p-finally": "^1.0.0" }, @@ -14542,6 +14629,15 @@ "node": ">=6" } }, + "node_modules/pusher-js": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz", + "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==", + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -14953,6 +15049,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", "engines": { "node": ">= 4" } @@ -15404,6 +15501,12 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/simple-wcswidth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", + "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -15759,7 +15862,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -16006,6 +16108,12 @@ "fsevents": "~2.3.3" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/twilio": { "version": "5.4.5", "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.4.5.tgz", @@ -16672,9 +16780,10 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.3.tgz", - "integrity": "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A==", + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", "peerDependencies": { "zod": "^3.24.1" } diff --git a/apps/rowboat/package.json b/apps/rowboat/package.json index 292a8f72..13506686 100644 --- a/apps/rowboat/package.json +++ b/apps/rowboat/package.json @@ -19,6 +19,7 @@ "@auth0/nextjs-auth0": "^4.7.0", "@aws-sdk/client-s3": "^3.743.0", "@aws-sdk/s3-request-presigner": "^3.743.0", + "@composio/core": "^0.1.36-next.7", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/docker-compose.yml b/docker-compose.yml index ad7d596d..67bc0a88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,8 @@ services: - USE_BILLING=${USE_BILLING} - BILLING_API_URL=${BILLING_API_URL} - BILLING_API_KEY=${BILLING_API_KEY} + - USE_COMPOSIO_TOOLS=${USE_COMPOSIO_TOOLS} + - COMPOSIO_API_KEY=${COMPOSIO_API_KEY} restart: unless-stopped volumes: - uploads:/app/uploads diff --git a/start.sh b/start.sh index d7ac1d47..0923f16b 100755 --- a/start.sh +++ b/start.sh @@ -9,6 +9,11 @@ mkdir -p data/mongo export USE_RAG=true export USE_RAG_UPLOADS=true +# enable composio tools if API key is set +if [ -n "$COMPOSIO_API_KEY" ]; then + export USE_COMPOSIO_TOOLS=true +fi + # Start with the base command and profile flags CMD="docker-compose" CMD="$CMD --profile setup_qdrant"