From 9157f87dc7e1aa60576537ca8cea7e281c00e150 Mon Sep 17 00:00:00 2001 From: akhisud3195 Date: Thu, 15 May 2025 18:24:25 +0530 Subject: [PATCH 1/4] Add hosted tools + revamp tools UX Replace db storage with api calls to klavis for list servers and add filters to hosted tool views Add logging and simplify oauth Refactor klavis API calls to go via a proxy Add projectAuthCheck() to klavis actions Fix build error in stream-response route.ts PARTIAL: Revamp tools modal PARTIAL: Manage mcp servers at project level document PARTIAL: Fetch tools from MCP servers upon toggle ON PARTIAL: Propogate hosted MCP tools to entity_list in build view Show tool toggle banner Add sync explicitly to prevent long page load time for MCP server's tools PARTIAL: Fix auth flow DB writes PARTIAL: Add tools with isready flag for auth-related server handling PARTIAL: Bring back sync tools CTA Fix tool selection issues PARTIAL: Fix sync issues with enriched and available tools and log unenriched tool names Remove buggy log statement Refactor common components and refactor HostedServer PARTIAL: Add custom servers and standardize the UI PARTIAL: Add modal and small UI improvements to custom servers page Show clubbed MCP tools in entity_list Add tool filters in tools section of entity_list Revert text in add tool CTA Make entity_list sections collapsed when one is expanded Merge project level tools to workflow level tools when sending requests to agent service Restore original panel-common variants Reduce agentic workflow request by removing tools from mcp servers Merge project level tools to workflow level tools when sending requests to copilot service Fix padding issues in entity_list headers Update package-lock.json Revert package* files to devg Revert tsconfig to dev PARTIAL: Change tabs and switch to heroui pending switch issues Fix switch issues with heroui Pass projectTools via workflow/app to entity_list and do not write to DB Fix issue with tool_config rendering and @ mentions for project tools Include @ mentioned project tools in agent request Update copilot usage of project tools Read mcp server url directly from tool config in agents service Make entity_list panels resizable Update resize handlers across the board Change Hosted MCP servers ---> Tools Library Remove tools filter Remove filter tabs in hosted tools Move tools selected / tools available labels below card titles Remove tools from config / settings page Bring back old mcp servers handling in agents service for backward compatibility as fallback Remove web_search from project template Add icons for agents, tools and prompts in entity_list Enable agents reordering in entity_list Fix build errors Make entity_list icons more transparent Add logos for hosted tools and fix importsg Fix server card component sizes and overflow Add error handling in hosted servers pageg Add node_modules to gitignore remove root package json add project auth checks revert to project mcpServers being optional refactor tool merging and conversion revert stream route change Move authURL klavis logic to klavis_actions Fix tool enrichment for post-auth tools and make logging less verbose Expand tool schema to include comprehensive json schema fields Add enabled and ready filters to hosted tools Add needs auth warning above auth button Update tools icon Add github and google client ids to docker-compose Clean up MCP servers upon project deletion Remove klavis ai label Improve server loading on and off UX Fix bug that was not enriching un-auth servers Add tool testing capabilities Fix un-blurred strip in tool testing modal view Disable server card CTAs during toggling on or off transition Add beta tag to tools Add tool and server counts Truncate long tool descriptions Add separators between filters in servers view Support multiple format types in tool testing fields Fix menu position issue for @ mentions --- apps/rowboat/app/actions/copilot_actions.ts | 35 +- .../app/actions/custom_server_actions.ts | 93 ++ apps/rowboat/app/actions/klavis_actions.ts | 842 ++++++++++++++++++ apps/rowboat/app/actions/mcp_actions.ts | 400 ++++++++- apps/rowboat/app/actions/project_actions.ts | 48 + .../app/api/v1/[projectId]/chat/route.ts | 12 +- .../widget/v1/chats/[chatId]/turn/route.ts | 12 +- apps/rowboat/app/lib/project_templates.ts | 9 - apps/rowboat/app/lib/project_tools.ts | 51 ++ .../rowboat/app/lib/types/agents_api_types.ts | 18 +- apps/rowboat/app/lib/types/project_types.ts | 40 +- apps/rowboat/app/lib/types/types.ts | 112 ++- apps/rowboat/app/lib/types/workflow_types.ts | 26 +- .../app/projects/[projectId]/config/app.tsx | 444 ++------- .../[projectId]/config/components/project.tsx | 38 +- .../[projectId]/config/components/tools.tsx | 316 ------- .../[projectId]/entities/agent_config.tsx | 6 +- .../[projectId]/entities/tool_config.tsx | 81 +- .../projects/[projectId]/playground/app.tsx | 5 +- .../playground/components/chat.tsx | 13 +- .../tools/components/CustomServers.tsx | 460 ++++++++++ .../tools/components/HostedServers.tsx | 663 ++++++++++++++ .../tools/components/MCPServersCommon.tsx | 498 +++++++++++ .../tools/components/TestToolModal.tsx | 674 ++++++++++++++ .../tools/components/ToolsConfig.tsx | 51 ++ .../tools/components/WebhookConfig.tsx | 127 +++ .../[projectId]/tools/oauth/callback/page.tsx | 21 + .../app/projects/[projectId]/tools/page.tsx | 19 + .../app/projects/[projectId]/workflow/app.tsx | 9 +- .../[projectId]/workflow/entity_list.tsx | 752 +++++++++++----- .../[projectId]/workflow/mcp_imports.tsx | 151 ---- .../[projectId]/workflow/workflow_editor.tsx | 171 ++-- .../projects/layout/components/sidebar.tsx | 23 +- apps/rowboat/app/styles/quill-mentions.css | 2 + .../components/common/panel-common.tsx | 23 +- apps/rowboat/components/ui/badge.tsx | 37 + apps/rowboat/components/ui/modal.tsx | 68 ++ apps/rowboat/components/ui/page-header.tsx | 27 + apps/rowboat/components/ui/slide-panel.tsx | 78 ++ apps/rowboat/components/ui/switch.tsx | 31 + apps/rowboat/components/ui/tabs.tsx | 17 + apps/rowboat/package-lock.json | 56 ++ apps/rowboat/package.json | 3 + .../public/mcp-server-images/discord.svg | 8 + .../public/mcp-server-images/doc2markdown.svg | 1 + .../public/mcp-server-images/firecrawl.webp | Bin 0 -> 322 bytes .../public/mcp-server-images/gcalendar.svg | 28 + .../public/mcp-server-images/gdocs.svg | 60 ++ .../public/mcp-server-images/gdrive.svg | 1 + .../public/mcp-server-images/github.svg | 1 + .../public/mcp-server-images/gmail.svg | 1 + .../public/mcp-server-images/gsheets.svg | 89 ++ .../rowboat/public/mcp-server-images/jira.svg | 1 + .../public/mcp-server-images/klavis.webp | Bin 0 -> 252 bytes .../mcp-server-images/markdown2doc.webp | Bin 0 -> 564 bytes .../public/mcp-server-images/notion.svg | 1 + .../public/mcp-server-images/postgres.svg | 22 + .../public/mcp-server-images/resend.svg | 3 + .../public/mcp-server-images/slack.svg | 6 + .../public/mcp-server-images/supabase.svg | 15 + .../public/mcp-server-images/wordpress.svg | 1 + .../public/mcp-server-images/youtube.svg | 11 + apps/rowboat_agents/src/graph/execute_turn.py | 53 +- docker-compose.yml | 3 + 64 files changed, 5625 insertions(+), 1242 deletions(-) create mode 100644 apps/rowboat/app/actions/custom_server_actions.ts create mode 100644 apps/rowboat/app/actions/klavis_actions.ts create mode 100644 apps/rowboat/app/lib/project_tools.ts delete mode 100644 apps/rowboat/app/projects/[projectId]/config/components/tools.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/CustomServers.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/HostedServers.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/MCPServersCommon.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/TestToolModal.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/WebhookConfig.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/oauth/callback/page.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/page.tsx delete mode 100644 apps/rowboat/app/projects/[projectId]/workflow/mcp_imports.tsx create mode 100644 apps/rowboat/components/ui/badge.tsx create mode 100644 apps/rowboat/components/ui/modal.tsx create mode 100644 apps/rowboat/components/ui/page-header.tsx create mode 100644 apps/rowboat/components/ui/slide-panel.tsx create mode 100644 apps/rowboat/components/ui/switch.tsx create mode 100644 apps/rowboat/components/ui/tabs.tsx create mode 100644 apps/rowboat/public/mcp-server-images/discord.svg create mode 100644 apps/rowboat/public/mcp-server-images/doc2markdown.svg create mode 100644 apps/rowboat/public/mcp-server-images/firecrawl.webp create mode 100644 apps/rowboat/public/mcp-server-images/gcalendar.svg create mode 100644 apps/rowboat/public/mcp-server-images/gdocs.svg create mode 100644 apps/rowboat/public/mcp-server-images/gdrive.svg create mode 100644 apps/rowboat/public/mcp-server-images/github.svg create mode 100644 apps/rowboat/public/mcp-server-images/gmail.svg create mode 100644 apps/rowboat/public/mcp-server-images/gsheets.svg create mode 100644 apps/rowboat/public/mcp-server-images/jira.svg create mode 100644 apps/rowboat/public/mcp-server-images/klavis.webp create mode 100644 apps/rowboat/public/mcp-server-images/markdown2doc.webp create mode 100644 apps/rowboat/public/mcp-server-images/notion.svg create mode 100644 apps/rowboat/public/mcp-server-images/postgres.svg create mode 100644 apps/rowboat/public/mcp-server-images/resend.svg create mode 100644 apps/rowboat/public/mcp-server-images/slack.svg create mode 100644 apps/rowboat/public/mcp-server-images/supabase.svg create mode 100644 apps/rowboat/public/mcp-server-images/wordpress.svg create mode 100644 apps/rowboat/public/mcp-server-images/youtube.svg diff --git a/apps/rowboat/app/actions/copilot_actions.ts b/apps/rowboat/app/actions/copilot_actions.ts index c58d8a9c..6162d5d4 100644 --- a/apps/rowboat/app/actions/copilot_actions.ts +++ b/apps/rowboat/app/actions/copilot_actions.ts @@ -15,6 +15,8 @@ import { check_query_limit } from "../lib/rate_limiting"; import { QueryLimitError, validateConfigChanges } from "../lib/client_utils"; import { projectAuthCheck } from "./project_actions"; import { redisClient } from "../lib/redis"; +import { fetchProjectMcpTools } from "../lib/project_tools"; +import { mergeProjectTools } from "../lib/types/project_types"; export async function getCopilotResponse( projectId: string, @@ -32,11 +34,20 @@ export async function getCopilotResponse( throw new QueryLimitError(); } + // Get MCP tools from project and merge with workflow tools + const mcpTools = await fetchProjectMcpTools(projectId); + + // Convert workflow to copilot format with both workflow and project tools + const copilotWorkflow = convertToCopilotWorkflow({ + ...current_workflow_config, + tools: await mergeProjectTools(current_workflow_config.tools, mcpTools) + }); + // prepare request const request: z.infer = { messages: messages.map(convertToCopilotApiMessage), workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)), - current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)), + current_workflow_config: JSON.stringify(copilotWorkflow), context: context ? convertToCopilotApiChatContext(context) : null, dataSources: dataSources ? dataSources.map(ds => { console.log('Original data source:', JSON.stringify(ds)); @@ -127,11 +138,20 @@ export async function getCopilotResponseStream( throw new QueryLimitError(); } + // Get MCP tools from project and merge with workflow tools + const mcpTools = await fetchProjectMcpTools(projectId); + + // Convert workflow to copilot format with both workflow and project tools + const copilotWorkflow = convertToCopilotWorkflow({ + ...current_workflow_config, + tools: await mergeProjectTools(current_workflow_config.tools, mcpTools) + }); + // prepare request const request: z.infer = { messages: messages.map(convertToCopilotApiMessage), workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)), - current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)), + current_workflow_config: JSON.stringify(copilotWorkflow), context: context ? convertToCopilotApiChatContext(context) : null, dataSources: dataSources ? dataSources.map(ds => CopilotDataSource.parse(ds)) : undefined, }; @@ -163,11 +183,20 @@ export async function getCopilotAgentInstructions( throw new QueryLimitError(); } + // Get MCP tools from project and merge with workflow tools + const mcpTools = await fetchProjectMcpTools(projectId); + + // Convert workflow to copilot format with both workflow and project tools + const copilotWorkflow = convertToCopilotWorkflow({ + ...current_workflow_config, + tools: await mergeProjectTools(current_workflow_config.tools, mcpTools) + }); + // prepare request const request: z.infer = { messages: messages.map(convertToCopilotApiMessage), workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)), - current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)), + current_workflow_config: JSON.stringify(copilotWorkflow), context: { type: 'agent', agentName: agentName, diff --git a/apps/rowboat/app/actions/custom_server_actions.ts b/apps/rowboat/app/actions/custom_server_actions.ts new file mode 100644 index 00000000..cb7a82c7 --- /dev/null +++ b/apps/rowboat/app/actions/custom_server_actions.ts @@ -0,0 +1,93 @@ +'use server'; + +import { projectsCollection } from '../lib/mongodb'; +import { MCPServer } from '../lib/types/types'; +import { z } from 'zod'; +import { projectAuthCheck } from './project_actions'; + +type McpServerType = z.infer; + +function formatServerUrl(url: string): string { + // Ensure URL starts with http:// or https:// + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'http://' + url; + } + // Remove trailing slash if present + return url.replace(/\/$/, ''); +} + +export async function fetchCustomServers(projectId: string) { + await projectAuthCheck(projectId); + + const project = await projectsCollection.findOne({ _id: projectId }); + return (project?.mcpServers || []) + .filter(server => server.serverType === 'custom') + .map(server => ({ + ...server, + serverType: 'custom' as const, + isReady: true // Custom servers are always ready + })); +} + +export async function addCustomServer(projectId: string, server: McpServerType) { + await projectAuthCheck(projectId); + + // Format the server URL and ensure isReady is true for custom servers + const formattedServer = { + ...server, + serverUrl: formatServerUrl(server.serverUrl || ''), + isReady: true // Custom servers are always ready + }; + + await projectsCollection.updateOne( + { _id: projectId }, + { $push: { mcpServers: formattedServer } } + ); + + return formattedServer; +} + +export async function removeCustomServer(projectId: string, serverName: string) { + await projectAuthCheck(projectId); + + await projectsCollection.updateOne( + { _id: projectId }, + { $pull: { mcpServers: { name: serverName } } } + ); +} + +export async function toggleCustomServer(projectId: string, serverName: string, isActive: boolean) { + await projectAuthCheck(projectId); + + await projectsCollection.updateOne( + { _id: projectId, "mcpServers.name": serverName }, + { + $set: { + "mcpServers.$.isActive": isActive, + "mcpServers.$.isReady": isActive // Update isReady along with isActive + } + } + ); +} + +export async function updateCustomServerTools( + projectId: string, + serverName: string, + tools: McpServerType['tools'], + availableTools?: McpServerType['availableTools'] +) { + await projectAuthCheck(projectId); + + const update: Record = { + "mcpServers.$.tools": tools + }; + + if (availableTools) { + update["mcpServers.$.availableTools"] = availableTools; + } + + await projectsCollection.updateOne( + { _id: projectId, "mcpServers.name": serverName }, + { $set: update } + ); +} \ No newline at end of file diff --git a/apps/rowboat/app/actions/klavis_actions.ts b/apps/rowboat/app/actions/klavis_actions.ts new file mode 100644 index 00000000..10b4d71c --- /dev/null +++ b/apps/rowboat/app/actions/klavis_actions.ts @@ -0,0 +1,842 @@ +'use server'; + +import { projectAuthCheck } from './project_actions'; +import { z } from 'zod'; +import { MCPServer, McpTool, McpServerResponse, McpServerTool } from '../lib/types/types'; +import { projectsCollection } from '../lib/mongodb'; +import { fetchMcpTools, toggleMcpTool } from './mcp_actions'; +import { fetchMcpToolsForServer } from './mcp_actions'; +import { headers } from 'next/headers'; + +type McpServerType = z.infer; +type McpToolType = z.infer; +type McpServerResponseType = z.infer; + +// Internal API Response Types +interface KlavisServerMetadata { + id: string; + name: string; + description: string; + tools: { + name: string; + description: string; + }[]; + authNeeded: boolean; +} + +interface GetAllServersResponse { + servers: KlavisServerMetadata[]; +} + +interface CreateServerInstanceResponse { + serverUrl: string; + instanceId: string; +} + +interface DeleteServerInstanceResponse { + success: boolean; + message: string; +} + +interface UserInstance { + id: string; + name: string; + description: string | null; + tools: { + name: string; + description: string; + authNeeded: boolean; + isAuthenticated: boolean; + }[] | null; + authNeeded: boolean; + isAuthenticated: boolean; +} + +interface GetUserInstancesResponse { + instances: UserInstance[]; +} + +// Add type for raw MCP tool response at the top with other types +interface RawMcpTool { + name: string; + description: string; + inputSchema: string | { + type: string; + properties: Record; + required?: string[]; + }; +} + +const KLAVIS_BASE_URL = 'https://api.klavis.ai'; + +interface KlavisApiCallOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + body?: Record; + additionalHeaders?: Record; +} + +async function klavisApiCall( + endpoint: string, + options: KlavisApiCallOptions = {} +): Promise { + const { method = 'GET', body, additionalHeaders = {} } = options; + const startTime = performance.now(); + const url = `${KLAVIS_BASE_URL}${endpoint}`; + + try { + const headers = { + 'Authorization': `Bearer ${process.env.KLAVIS_API_KEY}`, + 'Content-Type': 'application/json', + ...additionalHeaders + }; + + const fetchOptions: RequestInit = { + method, + headers, + ...(body ? { body: JSON.stringify(body) } : {}) + }; + + const response = await fetch(url, fetchOptions); + const endTime = performance.now(); + + console.log('[Klavis API] Response time:', { + url, + method, + durationMs: Math.round(endTime - startTime) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error); + } + + return await response.json() as T; + } catch (error) { + const endTime = performance.now(); + console.error('[Klavis API] Failed call:', { + url, + method, + durationMs: Math.round(endTime - startTime), + error + }); + throw error; + } +} + +// Lists all active server instances for a given project +export async function listActiveServerInstances(projectId: string): Promise { + try { + await projectAuthCheck(projectId); + + const queryParams = new URLSearchParams({ + user_id: projectId, + platform_name: 'Rowboat' + }); + + console.log('[Klavis API] Fetching active instances:', { projectId, platformName: 'Rowboat' }); + + const endpoint = `/user/instances?${queryParams}`; + const data = await klavisApiCall(endpoint); + + // Only show instances that are authenticated or need auth + const relevantInstances = data.instances.filter(i => i.isAuthenticated || i.authNeeded); + console.log('[Klavis API] Active instances:', { + count: relevantInstances.length, + authenticated: relevantInstances.filter(i => i.isAuthenticated).map(i => i.name).join(', '), + needsAuth: relevantInstances.filter(i => i.authNeeded && !i.isAuthenticated).map(i => i.name).join(', ') + }); + + return data.instances; + } catch (error) { + console.error('[Klavis API] Error listing active instances:', error); + throw error; + } +} + +async function enrichToolsWithParameters( + projectId: string, + serverName: string, + basicTools: { name: string; description: string }[], + isNewlyEnabled: boolean = false +): Promise { + try { + console.log(`[Klavis API] Starting tool enrichment for ${serverName}`); + const enrichedTools = await fetchMcpToolsForServer(projectId, serverName); + + if (enrichedTools.length === 0) { + console.log(`[Klavis API] No tools enriched for ${serverName}`); + return basicTools.map(tool => ({ + id: tool.name, + name: tool.name, + description: tool.description, + parameters: { + type: 'object', + properties: {}, + required: [] + } + })); + } + + console.log(`[Klavis API] Processing ${enrichedTools.length} tools for ${serverName}`); + + // Create a map of enriched tools for this server + const enrichedToolMap = new Map( + enrichedTools.map(tool => [tool.name, { + name: tool.name, + description: tool.description, + parameters: { + type: 'object' as const, + properties: tool.parameters?.properties || {}, + required: tool.parameters?.required || [] + } + }]) + ); + + // Find tools that couldn't be enriched + const unenrichedTools = basicTools + .filter(tool => !enrichedToolMap.has(tool.name)) + .map(tool => tool.name); + + if (unenrichedTools.length > 0) { + console.log('[Klavis API] Tools that could not be enriched:', { + serverName, + unenrichedTools: unenrichedTools.join(', ') + }); + } + + // Enrich the basic tools with parameters and descriptions + const result = basicTools.map(basicTool => { + const enrichedTool = enrichedToolMap.get(basicTool.name); + + const tool: McpToolType = { + id: basicTool.name, + name: basicTool.name, + description: enrichedTool?.description || basicTool.description || '', + parameters: enrichedTool?.parameters || { + type: 'object', + properties: {}, + required: [] + } + }; + + return tool; + }); + + console.log('[Klavis API] Tools processed:', { + serverName, + toolCount: result.length, + tools: result.map(t => ({ + name: t.name, + paramCount: Object.keys(t.parameters?.properties || {}).length, + hasParams: t.parameters && Object.keys(t.parameters.properties).length > 0 + })) + }); + + return result; + } catch (error) { + console.error('[Klavis API] Error enriching tools with parameters:', { + serverName, + error: error instanceof Error ? error.message : 'Unknown error', + basicToolCount: basicTools.length + }); + // Return basic tools with empty parameters if enrichment fails + return basicTools.map(tool => ({ + id: tool.name, + name: tool.name, + description: tool.description, + parameters: { + type: 'object', + properties: {}, + required: [] + } + })); + } +} + +// Modify listAvailableMcpServers to use enriched tools +export async function listAvailableMcpServers(projectId: string): Promise { + try { + await projectAuthCheck(projectId); + + console.log('[Klavis API] Starting server list fetch:', { projectId }); + + // Get MongoDB project data first + const project = await projectsCollection.findOne({ _id: projectId }); + const mongodbServers = project?.mcpServers || []; + const mongodbServerMap = new Map(mongodbServers.map(server => [server.name, server])); + + console.log('[Klavis API] Found ', mongodbServers.length, ' MongoDB servers'); + + const serversEndpoint = '/mcp-server/servers'; + const rawData = await klavisApiCall(serversEndpoint, { + additionalHeaders: { 'Accept': 'application/json' } + }); + + console.log('[Klavis API] Raw server response:', { + serverCount: rawData.servers.length, + servers: rawData.servers.map(s => s.name).join(', ') + }); + + if (!rawData || !rawData.servers || !Array.isArray(rawData.servers)) { + console.error('[Klavis API] Invalid response format:', rawData); + return { data: null, error: 'Invalid response format from server' }; + } + + // Get active instances for comparison + const queryParams = new URLSearchParams({ + user_id: projectId, + platform_name: 'Rowboat' + }); + + const instancesEndpoint = `/user/instances?${queryParams}`; + let activeInstances: UserInstance[] = []; + + try { + const instancesData = await klavisApiCall(instancesEndpoint); + activeInstances = instancesData.instances; + console.log('[Klavis API] Active instances:', { + count: activeInstances.length, + authenticated: activeInstances.filter(i => i.isAuthenticated).map(i => i.name).join(', '), + needsAuth: activeInstances.filter(i => i.authNeeded && !i.isAuthenticated).map(i => i.name).join(', ') + }); + } catch (error) { + console.error('[Klavis API] Failed to fetch user instances:', error); + } + + const activeInstanceMap = new Map(activeInstances.map(instance => [instance.name, instance])); + + // Transform and enrich the data + const transformedServers = []; + let eligibleCount = 0; + let serversWithToolsCount = 0; + + for (const server of rawData.servers) { + const activeInstance = activeInstanceMap.get(server.name); + const mongodbServer = mongodbServerMap.get(server.name); + + // Determine server eligibility + const isActive = !!activeInstance; + const authNeeded = activeInstance ? activeInstance.authNeeded : (server.authNeeded || false); + const isAuthenticated = activeInstance ? activeInstance.isAuthenticated : false; + const isEligible = isActive && (!authNeeded || isAuthenticated); + + // Get basic tools data first + const basicTools = (server.tools || []).map(tool => ({ + id: tool.name || '', + name: tool.name || '', + description: tool.description || '', + })); + + let availableTools: McpToolType[]; + let selectedTools: McpToolType[]; + + // Only use MongoDB data for eligible servers + if (isEligible) { + eligibleCount++; + console.log('[Klavis API] Processing server:', server.name); + + // Use MongoDB data if available + availableTools = mongodbServer?.availableTools || basicTools; + selectedTools = mongodbServer?.tools || []; + + if (selectedTools.length > 0) { + serversWithToolsCount++; + } + } else { + // For non-eligible servers, just use basic data + availableTools = basicTools; + selectedTools = []; + } + + transformedServers.push({ + ...server, + instanceId: activeInstance?.id || server.id, + serverName: server.name, + tools: selectedTools, + availableTools, + isActive, + authNeeded, + isAuthenticated, + requiresAuth: server.authNeeded || false, + serverUrl: mongodbServer?.serverUrl + }); + } + + console.log('[Klavis API] Server processing complete:', { + totalServers: transformedServers.length, + eligibleServers: eligibleCount, + serversWithTools: serversWithToolsCount + }); + + return { data: transformedServers, error: null }; + } catch (error: any) { + console.error('[Klavis API] Server list error:', error.message); + return { data: null, error: error.message || 'An unexpected error occurred' }; + } +} + +export async function createMcpServerInstance( + serverName: string, + projectId: string, + platformName: string, +): Promise { + try { + await projectAuthCheck(projectId); + + const requestBody = { + serverName, + userId: projectId, + platformName + }; + console.log('[Klavis API] Creating server instance:', requestBody); + + const endpoint = '/mcp-server/instance/create'; + const result = await klavisApiCall(endpoint, { + method: 'POST', + body: requestBody + }); + + console.log('[Klavis API] Created server instance:', result); + return result; + } catch (error: any) { + console.error('[Klavis API] Error creating instance:', error); + throw error; + } +} + +// Helper function to filter eligible servers +function getEligibleServers(servers: McpServerType[]): McpServerType[] { + return servers.filter(server => + server.isActive && (!server.authNeeded || server.isAuthenticated) + ); +} + +async function getServerInstance(instanceId: string): Promise<{ + instanceId: string; + authNeeded: boolean; + isAuthenticated: boolean; + serverName: string; + serverUrl?: string; +}> { + const endpoint = `/mcp-server/instance/get/${instanceId}`; + return await klavisApiCall(endpoint); +} + +export async function updateProjectServers(projectId: string, targetServerName?: string): Promise { + try { + await projectAuthCheck(projectId); + + console.log('[Auth] Starting server data update:', { projectId, targetServerName }); + + // Get current MongoDB data + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project) { + console.error('[Auth] Project not found in MongoDB:', { projectId }); + throw new Error("Project not found"); + } + + const mcpServers = project.mcpServers || []; + + // Get active instances to find auth status + const instances = await listActiveServerInstances(projectId); + + // If targetServerName is provided, only process that server + const instancesToProcess = targetServerName + ? instances.filter(i => i.name === targetServerName) + : instances; + + // For each active instance, get its current status + for (const instance of instancesToProcess) { + if (!instance.id) continue; + + try { + // Get fresh instance data + const serverInstance = await getServerInstance(instance.id); + + // Find this server in MongoDB + const serverIndex = mcpServers.findIndex(s => s.name === instance.name); + if (serverIndex === -1) continue; + + // Update server readiness based on auth status + const isReady = !serverInstance.authNeeded || serverInstance.isAuthenticated; + + // Update existing server + const updatedServer = { + ...mcpServers[serverIndex], + isAuthenticated: serverInstance.isAuthenticated, + isReady + }; + mcpServers[serverIndex] = updatedServer; + + // If server is now ready and has no tools, try to enrich them + if (isReady && (!updatedServer.tools || updatedServer.tools.length === 0)) { + try { + console.log(`[Auth] Enriching tools for ${instance.name}`); + const enrichedTools = await enrichToolsWithParameters( + projectId, + instance.name, + updatedServer.availableTools || [], + true + ); + + if (enrichedTools.length > 0) { + console.log(`[Auth] Writing ${enrichedTools.length} tools to DB for ${instance.name}`); + updatedServer.availableTools = enrichedTools; + await batchAddTools(projectId, instance.name, enrichedTools); + } + } catch (enrichError) { + console.error(`[Auth] Tool enrichment failed for ${instance.name}:`, enrichError); + } + } + } catch (error) { + console.error(`[Auth] Error updating ${instance.name}:`, error); + } + } + + // Update MongoDB with new server data + await projectsCollection.updateOne( + { _id: projectId }, + { $set: { mcpServers } } + ); + console.log('[Auth] MongoDB update completed'); + } catch (error) { + console.error('[Auth] Error updating server data:', error); + throw error; + } +} + +async function batchAddTools(projectId: string, serverName: string, tools: McpToolType[]): Promise { + console.log(`[Klavis API] Writing ${tools.length} tools to ${serverName}`); + + const toolsToWrite = tools.map(tool => ({ + id: tool.id, + name: tool.name, + description: tool.description, + parameters: tool.parameters || { + type: 'object', + properties: {}, + required: [] + } + })); + + console.log('[Klavis API] DB Write - batchAddTools:', { + serverName, + toolCount: tools.length, + tools: tools.map(t => t.name).join(', ') + }); + + // Update MongoDB in a single operation + await projectsCollection.updateOne( + { _id: projectId, "mcpServers.name": serverName }, + { + $set: { + "mcpServers.$.tools": toolsToWrite + } + } + ); + + console.log(`[Klavis API] Tools written to ${serverName}`); +} + +export async function enableServer( + serverName: string, + projectId: string, + enabled: boolean +): Promise { + try { + await projectAuthCheck(projectId); + + console.log('[Klavis API] Toggle server request:', { serverName, projectId, enabled }); + + if (enabled) { + console.log(`[Klavis API] Creating server instance for ${serverName}...`); + const result = await createMcpServerInstance(serverName, projectId, "Rowboat"); + console.log('[Klavis API] Server instance created:', { + serverName, + instanceId: result.instanceId, + serverUrl: result.serverUrl + }); + + // Get the current server list from MongoDB + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project) throw new Error("Project not found"); + + const mcpServers = project.mcpServers || []; + + // Find the server we're enabling + const serverIndex = mcpServers.findIndex(s => s.name === serverName); + const rawServerData = (await klavisApiCall('/mcp-server/servers')).servers + .find(s => s.name === serverName); + + if (!rawServerData) throw new Error("Server data not found"); + + // Get basic tools data + const basicTools = (rawServerData.tools || []).map(tool => ({ + id: tool.name || '', + name: tool.name || '', + description: tool.description || '', + })); + + // Update server status in MongoDB + const updatedServer = { + ...rawServerData, + instanceId: result.instanceId, + serverName: serverName, + serverUrl: result.serverUrl, + tools: basicTools, // Select all tools by default + availableTools: basicTools, // Use basic tools initially + isActive: true, // Keep isActive true to indicate server is enabled + isReady: !rawServerData.authNeeded, // Use isReady to indicate eligibility + authNeeded: rawServerData.authNeeded || false, + isAuthenticated: false, + requiresAuth: rawServerData.authNeeded || false + }; + + if (serverIndex === -1) { + mcpServers.push(updatedServer); + } else { + mcpServers[serverIndex] = updatedServer; + } + + // Update MongoDB with server status + await projectsCollection.updateOne( + { _id: projectId }, + { $set: { mcpServers } } + ); + + // Wait for server warm-up (increased from 2s to 5s) + console.log(`[Klavis API] New server detected, waiting 5s for ${serverName} to initialize...`); + await new Promise(resolve => setTimeout(resolve, 5000)); + console.log(`[Klavis API] Warm-up period complete for ${serverName}`); + + // Try to enrich tools regardless of auth status + try { + console.log(`[Klavis API] Enriching tools for ${serverName}`); + const enrichedTools = await enrichToolsWithParameters( + projectId, + serverName, + basicTools, + true + ); + + if (enrichedTools.length > 0) { + console.log(`[Klavis API] Writing ${enrichedTools.length} tools to DB for ${serverName}`); + // First update availableTools + await projectsCollection.updateOne( + { _id: projectId, "mcpServers.name": serverName }, + { + $set: { + "mcpServers.$.availableTools": enrichedTools, + "mcpServers.$.isReady": true // Mark server as ready after successful enrichment + } + } + ); + + // Then write the tools + await batchAddTools(projectId, serverName, enrichedTools); + console.log(`[Klavis API] Successfully wrote tools for ${serverName}`); + } + } catch (enrichError) { + console.error(`[Klavis API] Tool enrichment failed for ${serverName}:`, enrichError); + } + + return result; + } else { + // Get active instances to find the one to delete + const instances = await listActiveServerInstances(projectId); + const instance = instances.find(i => i.name === serverName); + + if (instance?.id) { + await deleteMcpServerInstance(instance.id, projectId); + console.log('[Klavis API] Disabled server:', { serverName, instanceId: instance.id }); + + // Remove from MongoDB + await projectsCollection.updateOne( + { _id: projectId }, + { $pull: { mcpServers: { name: serverName } } } + ); + } else { + console.log('[Klavis API] No instance found to disable:', { serverName }); + } + + return {}; + } + } catch (error: any) { + console.error('[Klavis API] Toggle error:', { + server: serverName, + error: error.message, + stack: error.stack, + timestamp: new Date().toISOString() + }); + throw error; + } +} + +export async function deleteMcpServerInstance( + instanceId: string, + projectId: string, +): Promise { + try { + await projectAuthCheck(projectId); + + console.log('[Klavis API] Deleting instance:', { instanceId }); + + const endpoint = `/mcp-server/instance/delete/${instanceId}`; + try { + await klavisApiCall(endpoint, { + method: 'DELETE' + }); + console.log('[Klavis API] Instance deleted successfully:', { instanceId }); + + // Get the server info from MongoDB to find its name + const project = await projectsCollection.findOne({ _id: projectId }); + const server = project?.mcpServers?.find(s => s.instanceId === instanceId); + + if (server) { + // Update just this server's status in MongoDB + await projectsCollection.updateOne( + { _id: projectId, "mcpServers.name": server.name }, + { + $set: { + "mcpServers.$.isActive": false, + "mcpServers.$.serverUrl": null, + "mcpServers.$.tools": [], + "mcpServers.$.availableTools": [], + "mcpServers.$.instanceId": null + } + } + ); + console.log('[MongoDB] Server status updated:', { serverName: server.name }); + } + } catch (error: any) { + if (error.message.includes('404')) { + console.log('[Klavis API] Instance already deleted:', { instanceId }); + return; + } + throw error; + } + } catch (error: any) { + console.error('[Klavis API] Error deleting instance:', error); + throw error; + } +} + +// Server name to URL parameter mapping +const SERVER_URL_PARAMS: Record = { + 'Google Calendar': 'gcalendar', + 'Google Drive': 'gdrive', + 'Google Docs': 'gdocs', + 'Google Sheets': 'gsheets', +}; + +// Server name to environment variable mapping for client IDs +const SERVER_CLIENT_ID_MAP: Record = { + 'GitHub': process.env.KLAVIS_GITHUB_CLIENT_ID, + 'Google Calendar': process.env.KLAVIS_GOOGLE_CLIENT_ID, + 'Google Drive': process.env.KLAVIS_GOOGLE_CLIENT_ID, + 'Google Docs': process.env.KLAVIS_GOOGLE_CLIENT_ID, + 'Google Sheets': process.env.KLAVIS_GOOGLE_CLIENT_ID, + 'Slack': process.env.KLAVIS_SLACK_ID, +}; + +export async function generateServerAuthUrl( + serverName: string, + projectId: string, + instanceId: string, +): Promise { + try { + await projectAuthCheck(projectId); + + // Get the origin from request headers + const headersList = headers(); + const host = headersList.get('host') || ''; + const protocol = headersList.get('x-forwarded-proto') || 'http'; + const origin = `${protocol}://${host}`; + + // Get the URL parameter for this server + const serverUrlParam = SERVER_URL_PARAMS[serverName] || serverName.toLowerCase(); + + // Build base params + const params: Record = { + instance_id: instanceId, + redirect_url: `${origin}/projects/${projectId}/tools/oauth/callback` + }; + + // Add client_id if available for this server + const clientId = SERVER_CLIENT_ID_MAP[serverName]; + if (clientId) { + params.client_id = clientId; + } + + let authUrl = `${KLAVIS_BASE_URL}/oauth/${serverUrlParam}/authorize?${new URLSearchParams(params).toString()}` + console.log('authUrl', authUrl); + + return authUrl; + } catch (error) { + console.error('[Klavis API] Error generating auth URL:', error); + throw error; + } +} + +export async function syncServerTools(projectId: string, serverName: string): Promise { + try { + await projectAuthCheck(projectId); + + console.log('[Klavis API] Starting server tool sync:', { projectId, serverName }); + + // Get enriched tools from MCP + const enrichedTools = await fetchMcpToolsForServer(projectId, serverName); + console.log('[Klavis API] Received enriched tools:', { + serverName, + toolCount: enrichedTools.length + }); + + // Convert enriched tools to the correct format + const formattedTools = enrichedTools.map(tool => { + return { + id: tool.name, + name: tool.name, + description: tool.description, + parameters: { + type: 'object' as const, + properties: tool.parameters?.properties || {}, + required: tool.parameters?.required || [] + } + }; + }); + + // First verify the server exists + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project) { + throw new Error(`Project ${projectId} not found`); + } + const server = project.mcpServers?.find(s => s.name === serverName); + if (!server) { + throw new Error(`Server ${serverName} not found in project ${projectId}`); + } + + // Update MongoDB with enriched tools + const updateResult = await projectsCollection.updateOne( + { _id: projectId, "mcpServers.name": serverName }, + { + $set: { + "mcpServers.$.availableTools": formattedTools, + "mcpServers.$.tools": formattedTools // Also update selected tools to match + } + } + ); + + console.log('[Klavis API] Tools synced:', { + serverName, + toolCount: formattedTools.length, + success: updateResult.modifiedCount > 0 + }); + + } catch (error) { + console.error('[Klavis API] Error syncing server tools:', { + serverName, + error: error instanceof Error ? error.message : 'Unknown error' + }); + throw error; + } +} \ No newline at end of file diff --git a/apps/rowboat/app/actions/mcp_actions.ts b/apps/rowboat/app/actions/mcp_actions.ts index 9ca6668f..6ca18356 100644 --- a/apps/rowboat/app/actions/mcp_actions.ts +++ b/apps/rowboat/app/actions/mcp_actions.ts @@ -4,9 +4,10 @@ import { WorkflowTool } from "../lib/types/workflow_types"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { projectAuthCheck } from "./project_actions"; -import { projectsCollection } from "../lib/mongodb"; +import { projectsCollection, agentWorkflowsCollection } from "../lib/mongodb"; import { Project } from "../lib/types/project_types"; -import { MCPServer } from "../lib/types/types"; +import { MCPServer, McpTool, McpServerTool, convertMcpServerToolToWorkflowTool } from "../lib/types/types"; +import { ObjectId } from "mongodb"; export async function fetchMcpTools(projectId: string): Promise[]> { await projectAuthCheck(projectId); @@ -16,12 +17,13 @@ export async function fetchMcpTools(projectId: string): Promise[] = []; for (const mcpServer of mcpServers) { + if (!mcpServer.isActive) continue; + try { - const transport = new SSEClientTransport(new URL(mcpServer.url)); + const transport = new SSEClientTransport(new URL(mcpServer.serverUrl!)); const client = new Client( { @@ -42,31 +44,158 @@ export async function fetchMcpTools(projectId: string): Promise { + try { + return McpServerTool.parse(tool); + } catch (error) { + console.error(`Invalid tool response from ${mcpServer.name}:`, { + tool: tool.name, + error: error instanceof Error ? error.message : 'Unknown error' + }); + return null; + } + }) + ); - tools.push(...result.tools.map((mcpTool) => { - let props = mcpTool.inputSchema.properties as Record; - const tool: z.infer = { - name: mcpTool.name, - description: mcpTool.description ?? "", - parameters: { - type: "object", - properties: props ?? {}, - required: mcpTool.inputSchema.required as string[] ?? [], - }, - isMcp: true, - mcpServerName: mcpServer.name, - } - return tool; - })); + // Filter out invalid tools and convert valid ones + tools.push(...validTools + .filter((tool): tool is z.infer => + tool !== null && + mcpServer.tools.some(t => t.id === tool.name) + ) + .map(mcpTool => convertMcpServerToolToWorkflowTool(mcpTool, mcpServer)) + ); } catch (e) { - console.error(`Error fetching MCP tools from ${mcpServer.name}: ${e}`); + console.error(`Error fetching MCP tools from ${mcpServer.name}:`, { + error: e instanceof Error ? e.message : 'Unknown error', + serverUrl: mcpServer.serverUrl + }); } } return tools; } +export async function fetchMcpToolsForServer(projectId: string, serverName: string): Promise[]> { + await projectAuthCheck(projectId); + + console.log('[Klavis API] Fetching tools for specific server:', { projectId, serverName }); + + const project = await projectsCollection.findOne({ + _id: projectId, + }); + + const mcpServer = project?.mcpServers?.find(server => server.name === serverName); + if (!mcpServer) { + console.error('[Klavis API] Server not found:', { serverName }); + return []; + } + + if (!mcpServer.isActive || !mcpServer.serverUrl) { + console.log('[Klavis API] Server is not active or missing URL:', { + serverName, + isActive: mcpServer.isActive, + hasUrl: !!mcpServer.serverUrl + }); + return []; + } + + const tools: z.infer[] = []; + + try { + console.log('[Klavis API] Attempting MCP connection:', { + serverName, + url: mcpServer.serverUrl + }); + + const transport = new SSEClientTransport(new URL(mcpServer.serverUrl)); + const client = new Client( + { + name: "rowboat-client", + version: "1.0.0" + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {} + } + } + ); + + await client.connect(transport); + console.log('[Klavis API] MCP connection established:', { serverName }); + + // List tools + const result = await client.listTools(); + + // Log just essential info about tools + console.log('[Klavis API] Received tools from server:', { + serverName, + toolCount: result.tools.length, + tools: result.tools.map(tool => tool.name).join(', ') + }); + + // Get all available tools from the server + const availableToolNames = new Set(mcpServer.availableTools?.map(t => t.name) || []); + + // Validate and parse each tool + const validTools = await Promise.all( + result.tools.map(async (tool) => { + try { + const parsedTool = McpServerTool.parse(tool); + return parsedTool; + } catch (error) { + console.error(`Invalid tool response from ${mcpServer.name}:`, { + tool: tool.name, + error: error instanceof Error ? error.message : 'Unknown error' + }); + return null; + } + }) + ); + + // Filter out invalid tools and convert valid ones + const convertedTools = validTools + .filter((tool): tool is z.infer => tool !== null) + .map(mcpTool => { + const converted = convertMcpServerToolToWorkflowTool(mcpTool, mcpServer); + return converted; + }); + + tools.push(...convertedTools); + + // Find tools that weren't enriched + const enrichedToolNames = new Set(convertedTools.map(t => t.name)); + const unenrichedTools = Array.from(availableToolNames).filter(name => !enrichedToolNames.has(name)); + + if (unenrichedTools.length > 0) { + console.log('[Klavis API] Tools that could not be enriched:', { + serverName, + unenrichedTools, + totalAvailable: availableToolNames.size, + totalEnriched: enrichedToolNames.size + }); + } + + console.log('[Klavis API] Successfully fetched tools for server:', { + serverName, + toolCount: tools.length, + availableToolCount: availableToolNames.size, + tools: tools.map(t => t.name).join(', ') + }); + } catch (e) { + console.error(`[Klavis API] Error fetching MCP tools from ${mcpServer.name}:`, { + error: e instanceof Error ? e.message : 'Unknown error', + serverUrl: mcpServer.serverUrl + }); + } + + return tools; +} + export async function updateMcpServers(projectId: string, mcpServers: z.infer['mcpServers']): Promise { await projectAuthCheck(projectId); await projectsCollection.updateOne({ @@ -80,4 +209,233 @@ export async function listMcpServers(projectId: string): Promise, + toolId: string, + shouldAdd: boolean +): Promise { + await projectAuthCheck(projectId); + + // 1. Get all workflows in the project + const workflows = await agentWorkflowsCollection.find({ projectId }).toArray(); + + // 2. For each workflow + for (const workflow of workflows) { + // 3. Find if the tool already exists in this workflow + const existingTool = workflow.tools.find(t => + t.isMcp && + t.mcpServerName === mcpServer.name && + t.name === toolId + ); + + if (shouldAdd && !existingTool) { + // 4a. If adding and tool doesn't exist, add it + const tool = mcpServer.tools.find(t => t.id === toolId); + if (tool) { + const workflowTool = convertMcpServerToolToWorkflowTool( + { + name: tool.name, + description: tool.description, + inputSchema: { + type: 'object', + properties: tool.parameters?.properties ?? {}, + required: tool.parameters?.required ?? [], + }, + }, + mcpServer + ); + workflow.tools.push(workflowTool); + } + } else if (!shouldAdd && existingTool) { + // 4b. If removing and tool exists, remove it + workflow.tools = workflow.tools.filter(t => + !(t.isMcp && t.mcpServerName === mcpServer.name && t.name === toolId) + ); + } + + // 5. Update the workflow + await agentWorkflowsCollection.updateOne( + { _id: workflow._id }, + { + $set: { + tools: workflow.tools, + lastUpdatedAt: new Date().toISOString() + } + } + ); + } +} + +export async function toggleMcpTool( + projectId: string, + serverName: string, + toolId: string, + shouldAdd: boolean +): Promise { + await projectAuthCheck(projectId); + + // 1. Get the project and find the server + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project) throw new Error("Project not found"); + + const mcpServers = project.mcpServers || []; + const serverIndex = mcpServers.findIndex(s => s.serverName === serverName); + if (serverIndex === -1) throw new Error("Server not found"); + + const server = mcpServers[serverIndex]; + + if (shouldAdd) { + // Add tool if it doesn't exist + const toolExists = server.tools.some(t => t.id === toolId); + if (!toolExists) { + // Find the tool in availableTools to get its parameters + const availableTool = server.availableTools?.find(t => t.name === toolId); + + // Create a new tool with the parameters from availableTools + const newTool = { + id: toolId, + name: toolId, + description: availableTool?.description || '', + parameters: availableTool?.parameters || { + type: 'object' as const, + properties: {}, + required: [] + } + }; + server.tools.push(newTool); + } + } else { + // Remove tool if it exists + server.tools = server.tools.filter(t => t.id !== toolId); + } + + // Update the project + await projectsCollection.updateOne( + { _id: projectId }, + { $set: { mcpServers } } + ); +} + +export async function getSelectedMcpTools(projectId: string, serverName: string): Promise { + await projectAuthCheck(projectId); + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project) return []; + + const server = project.mcpServers?.find(s => s.serverName === serverName); + if (!server) return []; + + 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, + toolId: string, + parameters: Record +): Promise { + await projectAuthCheck(projectId); + + const project = await projectsCollection.findOne({ + _id: projectId, + }); + + // Find the server by name in mcpServers array + const mcpServer = project?.mcpServers?.find(server => server.name === serverName); + if (!mcpServer) { + throw new Error(`Server ${serverName} not found`); + } + + if (!mcpServer.isActive) { + throw new Error(`Server ${serverName} is not active`); + } + + if (!mcpServer.serverUrl) { + throw new Error(`Server ${serverName} has no URL configured`); + } + + try { + console.log('[MCP Test] Attempting to test tool:', { + serverName, + serverUrl: mcpServer.serverUrl, + toolId + }); + + const transport = new SSEClientTransport(new URL(mcpServer.serverUrl)); + const client = new Client( + { + name: "rowboat-client", + version: "1.0.0" + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {} + } + } + ); + + await client.connect(transport); + + console.log('[MCP Test] Connected to server, calling tool:', { + toolId, + parameters + }); + + // Execute the tool with the correct parameter format + const result = await client.callTool({ + name: toolId, + arguments: parameters + }); + + console.log('[MCP Test] Tool execution completed:', { + toolId, + success: true + }); + + return result; + + } catch (e) { + console.error(`[MCP Test] Error testing tool from ${mcpServer.name}:`, { + error: e instanceof Error ? e.message : 'Unknown error', + serverUrl: mcpServer.serverUrl, + toolId, + parameters + }); + throw e; + } } \ No newline at end of file diff --git a/apps/rowboat/app/actions/project_actions.ts b/apps/rowboat/app/actions/project_actions.ts index 0090aec9..832042e7 100644 --- a/apps/rowboat/app/actions/project_actions.ts +++ b/apps/rowboat/app/actions/project_actions.ts @@ -11,6 +11,7 @@ import { WithStringId } from "../lib/types/types"; import { ApiKey } from "../lib/types/project_types"; import { Project } from "../lib/types/project_types"; import { USE_AUTH } from "../lib/feature_flags"; +import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions"; export async function projectAuthCheck(projectId: string) { if (!USE_AUTH) { @@ -171,9 +172,56 @@ export async function updateProjectName(projectId: string, name: string) { revalidatePath(`/projects/${projectId}`, 'layout'); } +interface McpServerDeletionError { + serverName: string; + error: string; +} + +async function cleanupMcpServers(projectId: string): Promise { + // Get all active instances directly from Klavis + const activeInstances = await listActiveServerInstances(projectId); + if (activeInstances.length === 0) return []; + + console.log(`[Project Cleanup] Found ${activeInstances.length} active Klavis instances`); + + // Track deletion errors + const deletionErrors: McpServerDeletionError[] = []; + + // Delete each instance + const deletionPromises = activeInstances.map(async (instance) => { + if (!instance.id) return; // Skip if no instance ID + + try { + await deleteMcpServerInstance(instance.id, projectId); + console.log(`[Project Cleanup] Deleted Klavis instance: ${instance.name} (${instance.id})`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[Project Cleanup] Failed to delete Klavis instance: ${instance.name}`, error); + deletionErrors.push({ + serverName: instance.name, + error: errorMessage + }); + } + }); + + // Wait for all deletions to complete + await Promise.all(deletionPromises); + + return deletionErrors; +} + export async function deleteProject(projectId: string) { await projectAuthCheck(projectId); + // First cleanup any Klavis instances + const deletionErrors = await cleanupMcpServers(projectId); + + // If there were any errors deleting instances, throw an error + if (deletionErrors.length > 0) { + const failedServers = deletionErrors.map(e => `${e.serverName} (${e.error})`).join(', '); + throw new Error(`Cannot delete project because the following Klavis instances could not be deleted: ${failedServers}. Please try again or contact support if the issue persists.`); + } + // delete api keys await apiKeysCollection.deleteMany({ projectId, diff --git a/apps/rowboat/app/api/v1/[projectId]/chat/route.ts b/apps/rowboat/app/api/v1/[projectId]/chat/route.ts index 7d350b84..a07beae6 100644 --- a/apps/rowboat/app/api/v1/[projectId]/chat/route.ts +++ b/apps/rowboat/app/api/v1/[projectId]/chat/route.ts @@ -9,6 +9,7 @@ import { getAgenticApiResponse } from "../../../../lib/utils"; 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"; // get next turn / agent response export async function POST( @@ -54,6 +55,9 @@ export async function POST( return Response.json({ error: "Project not found" }, { status: 404 }); } + // fetch project tools + const projectTools = await fetchProjectMcpTools(projectId); + // if workflow id is provided in the request, use it, else use the published workflow id let workflowId = result.data.workflowId ?? project.publishedWorkflowId; if (!workflowId) { @@ -86,7 +90,7 @@ export async function POST( let currentState: unknown = reqState ?? { last_agent_name: workflow.agents[0].name }; // get assistant response - const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow); + const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools); const request: z.infer = { projectId, messages: convertFromApiToAgenticApiMessages(reqMessages), @@ -96,7 +100,11 @@ export async function POST( prompts, startAgent, testProfile: testProfile ?? undefined, - mcpServers: project.mcpServers ?? [], + mcpServers: (project.mcpServers ?? []).map(server => ({ + name: server.name, + serverUrl: server.serverUrl ?? '', + isReady: server.isReady ?? false + })), toolWebhookUrl: project.webhookUrl ?? '', }; 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 d5d2c1bf..5ea47a3b 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 @@ -11,6 +11,7 @@ import { AgenticAPIChatRequest } from "../../../../../../lib/types/agents_api_ty import { getAgenticApiResponse } from "../../../../../../lib/utils"; import { check_query_limit } from "../../../../../../lib/rate_limiting"; import { PrefixLogger } from "../../../../../../lib/utils"; +import { fetchProjectMcpTools } from "@/app/lib/project_tools"; // get next turn / agent response export async function POST( @@ -80,6 +81,9 @@ export async function POST( throw new Error("Project settings not found"); } + // fetch project tools + const projectTools = await fetchProjectMcpTools(session.projectId); + // fetch workflow const workflow = await agentWorkflowsCollection.findOne({ projectId: session.projectId, @@ -90,7 +94,7 @@ export async function POST( } // get assistant response - const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow); + const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools); const unsavedMessages: z.infer[] = [userMessage]; let state: unknown = chat.agenticState ?? { last_agent_name: startAgent }; @@ -102,7 +106,11 @@ export async function POST( tools, prompts, startAgent, - mcpServers: projectSettings.mcpServers ?? [], + mcpServers: (projectSettings.mcpServers ?? []).map(server => ({ + name: server.name, + serverUrl: server.serverUrl || '', + isReady: server.isReady + })), toolWebhookUrl: projectSettings.webhookUrl ?? '', testProfile: undefined, }; diff --git a/apps/rowboat/app/lib/project_templates.ts b/apps/rowboat/app/lib/project_templates.ts index 25eac0fd..20f42e34 100644 --- a/apps/rowboat/app/lib/project_templates.ts +++ b/apps/rowboat/app/lib/project_templates.ts @@ -25,15 +25,6 @@ export const templates: { [key: string]: z.infer } = { ], prompts: [], tools: [ - { - "name": "web_search", - "description": "Fetch information from the web based on chat context", - "parameters": { - "type": "object", - "properties": {}, - }, - "isLibrary": true - }, { "name": "rag_search", "description": "Fetch articles with knowledge relevant to the query", diff --git a/apps/rowboat/app/lib/project_tools.ts b/apps/rowboat/app/lib/project_tools.ts new file mode 100644 index 00000000..8d40cdc4 --- /dev/null +++ b/apps/rowboat/app/lib/project_tools.ts @@ -0,0 +1,51 @@ +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 []; + + 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 || [] + })) + })) + }); + + // 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, + description: tool.description || "", + parameters: { + type: 'object' as const, + properties: tool.parameters?.properties || {}, + required: tool.parameters?.required || [] + }, + isMcp: true, + mcpServerName: server.name, + mcpServerURL: server.serverUrl, + })); + }); + + 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; +} diff --git a/apps/rowboat/app/lib/types/agents_api_types.ts b/apps/rowboat/app/lib/types/agents_api_types.ts index 6dac9b8d..bf8f8263 100644 --- a/apps/rowboat/app/lib/types/agents_api_types.ts +++ b/apps/rowboat/app/lib/types/agents_api_types.ts @@ -3,7 +3,8 @@ import { sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, Work import { apiV1 } from "rowboat-shared"; import { ApiMessage } from "./types"; import { TestProfile } from "./testing_types"; -import { MCPServer } from "./types"; +import { MCPServer, MCPServerMinimal } from "./types"; +import { mergeProjectTools } from "./project_types"; export const AgenticAPIChatMessage = z.object({ role: z.union([z.literal('user'), z.literal('assistant'), z.literal('tool'), z.literal('system')]), @@ -55,7 +56,7 @@ export const AgenticAPIChatRequest = z.object({ prompts: z.array(WorkflowPrompt), startAgent: z.string(), testProfile: TestProfile.optional(), - mcpServers: z.array(MCPServer), + mcpServers: z.array(MCPServerMinimal), toolWebhookUrl: z.string(), }); @@ -68,19 +69,21 @@ export const AgenticAPIInitStreamResponse = z.object({ streamId: z.string(), }); -export function convertWorkflowToAgenticAPI(workflow: z.infer): { +export function convertWorkflowToAgenticAPI(workflow: z.infer, projectTools: z.infer[]): { agents: z.infer[]; tools: z.infer[]; prompts: z.infer[]; startAgent: string; } { + const mergedTools = mergeProjectTools(workflow.tools, projectTools); + return { agents: workflow.agents .filter(agent => !agent.disabled) .map(agent => { const compiledInstructions = agent.instructions + (agent.examples ? '\n\n# Examples\n' + agent.examples : ''); - const { sanitized, entities } = sanitizeTextWithMentions(compiledInstructions, workflow); + const { sanitized, entities } = sanitizeTextWithMentions(compiledInstructions, workflow, mergedTools); const agenticAgent: z.infer = { name: agent.name, @@ -100,13 +103,10 @@ export function convertWorkflowToAgenticAPI(workflow: z.infer): }; return agenticAgent; }), - tools: workflow.tools.map(tool => { - const { autoSubmitMockedResponse, ...rest } = tool; - return rest; - }), + tools: mergedTools, prompts: workflow.prompts .map(p => { - const { sanitized } = sanitizeTextWithMentions(p.prompt, workflow); + const { sanitized } = sanitizeTextWithMentions(p.prompt, workflow, mergedTools); return { ...p, prompt: sanitized, diff --git a/apps/rowboat/app/lib/types/project_types.ts b/apps/rowboat/app/lib/types/project_types.ts index 6016e609..e5084107 100644 --- a/apps/rowboat/app/lib/types/project_types.ts +++ b/apps/rowboat/app/lib/types/project_types.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { MCPServer } from "./types"; +import { WorkflowTool } from "./workflow_types"; export const Project = z.object({ _id: z.string().uuid(), @@ -28,4 +29,41 @@ export const ApiKey = z.object({ key: z.string(), createdAt: z.string().datetime(), lastUsedAt: z.string().datetime().optional(), -}); \ No newline at end of file +}); + +export function mergeProjectTools( + workflowTools: z.infer[], + projectTools: z.infer[] +): z.infer[] { + // Filter out any existing MCP tools from workflow tools + const nonMcpTools = workflowTools.filter(t => !t.isMcp); + + // Merge with MCP 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 || [] + } + })) + ]; + + 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/types.ts b/apps/rowboat/app/lib/types/types.ts index c4544d6b..a30c1a04 100644 --- a/apps/rowboat/app/lib/types/types.ts +++ b/apps/rowboat/app/lib/types/types.ts @@ -1,10 +1,79 @@ import { CoreMessage, ToolCallPart } from "ai"; import { z } from "zod"; import { apiV1 } from "rowboat-shared"; +import { WorkflowTool } from "./workflow_types"; + +export const McpToolInputSchema = z.object({ + type: z.literal('object'), + properties: z.record(z.object({ + type: z.string(), + description: z.string(), + enum: z.array(z.any()).optional(), + default: z.any().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + items: z.any().optional(), // For array types + format: z.string().optional(), + pattern: z.string().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + uniqueItems: z.boolean().optional(), + multipleOf: z.number().optional(), + examples: z.array(z.any()).optional(), + })).default({}), + required: z.array(z.string()).default([]), +}); + +export const McpServerTool = z.object({ + name: z.string(), + description: z.string().optional(), + inputSchema: McpToolInputSchema.optional(), +}); + +export const McpTool = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + parameters: z.object({ + type: z.literal('object'), + properties: z.record(z.object({ + type: z.string(), + description: z.string(), + })), + required: z.array(z.string()).optional(), + }).optional(), +}); export const MCPServer = z.object({ + id: z.string(), name: z.string(), - url: z.string(), + description: z.string(), + tools: z.array(McpTool), // Selected tools from MongoDB + availableTools: z.array(McpTool).optional(), // Available tools from Klavis + isActive: z.boolean().optional(), + isReady: z.boolean().optional(), + authNeeded: z.boolean().optional(), + isAuthenticated: z.boolean().optional(), + requiresAuth: z.boolean().optional(), + serverUrl: z.string().optional(), + instanceId: z.string().optional(), + serverName: z.string().optional(), + serverType: z.enum(['hosted', 'custom']).optional(), +}); + +// Minimal MCP server info needed by agents service +export const MCPServerMinimal = z.object({ + name: z.string(), + serverUrl: z.string(), + isReady: z.boolean().optional(), +}); + +// Response types for Klavis API +export const McpServerResponse = z.object({ + data: z.array(z.lazy(() => MCPServer)).nullable(), + error: z.string().nullable(), }); export const PlaygroundChat = z.object({ @@ -119,3 +188,44 @@ export const ApiResponse = z.object({ messages: z.array(ApiMessage), state: z.unknown(), }); + +// Helper function to convert MCP server tool to WorkflowTool +export function convertMcpServerToolToWorkflowTool( + mcpTool: z.infer, + mcpServer: z.infer +): z.infer { + // Parse the input schema, handling both string and object formats + let parsedSchema; + if (typeof mcpTool.inputSchema === 'string') { + try { + parsedSchema = JSON.parse(mcpTool.inputSchema); + } catch (e) { + console.error('Failed to parse inputSchema string:', e); + parsedSchema = { + type: 'object', + properties: {}, + required: [] + }; + } + } else { + parsedSchema = mcpTool.inputSchema ?? { + type: 'object', + properties: {}, + required: [] + }; + } + + // Ensure the schema is valid + const inputSchema = McpToolInputSchema.parse(parsedSchema); + + const converted = { + name: mcpTool.name, + description: mcpTool.description ?? "", + parameters: inputSchema, + isMcp: true, + mcpServerName: mcpServer.name, + mcpServerURL: mcpServer.serverUrl, + }; + + return converted; +} diff --git a/apps/rowboat/app/lib/types/workflow_types.ts b/apps/rowboat/app/lib/types/workflow_types.ts index e0a57786..7c596291 100644 --- a/apps/rowboat/app/lib/types/workflow_types.ts +++ b/apps/rowboat/app/lib/types/workflow_types.ts @@ -1,6 +1,7 @@ import { z } from "zod"; export const WorkflowAgent = z.object({ name: z.string(), + order: z.number().int().optional(), type: z.union([ z.literal('conversation'), z.literal('post_process'), @@ -41,12 +42,31 @@ export const WorkflowTool = z.object({ properties: z.record(z.object({ type: z.string(), description: z.string(), + enum: z.array(z.any()).optional(), + default: z.any().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + items: z.any().optional(), // For array types + format: z.string().optional(), + pattern: z.string().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + uniqueItems: z.boolean().optional(), + multipleOf: z.number().optional(), + examples: z.array(z.any()).optional(), })), - required: z.array(z.string()).optional(), + required: z.array(z.string()).default([]), + }).default({ + type: 'object', + properties: {}, + required: [], }), isMcp: z.boolean().default(false).optional(), isLibrary: z.boolean().default(false).optional(), mcpServerName: z.string().optional(), + mcpServerURL: z.string().optional(), }); export const Workflow = z.object({ name: z.string().optional(), @@ -81,6 +101,7 @@ export function sanitizeTextWithMentions( tools: z.infer[], prompts: z.infer[], }, + projectTools: z.infer[] = [] ): { sanitized: string; entities: z.infer[]; @@ -110,7 +131,8 @@ export function sanitizeTextWithMentions( if (entity.type === 'agent') { return workflow.agents.some(a => a.name === entity.name); } else if (entity.type === 'tool') { - return workflow.tools.some(t => t.name === entity.name); + return workflow.tools.some(t => t.name === entity.name) || + projectTools.some(t => t.name === entity.name); } else if (entity.type === 'prompt') { return workflow.prompts.some(p => p.name === entity.name); } diff --git a/apps/rowboat/app/projects/[projectId]/config/app.tsx b/apps/rowboat/app/projects/[projectId]/config/app.tsx index 1aa68727..a81ca4fb 100644 --- a/apps/rowboat/app/projects/[projectId]/config/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/config/app.tsx @@ -2,32 +2,19 @@ import { Metadata } from "next"; import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider } from "@heroui/react"; -import { ReactNode, useEffect, useState, useCallback, useMemo } from "react"; +import { ReactNode, useEffect, useState } from "react"; import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions"; -import { updateMcpServers } from "../../../actions/mcp_actions"; import { CopyButton } from "../../../../components/common/copy-button"; import { EditableField } from "../../../lib/components/editable-field"; -import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon, CheckCircleIcon, XCircleIcon } from "lucide-react"; +import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react"; import { WithStringId } from "../../../lib/types/types"; import { ApiKey } from "../../../lib/types/project_types"; import { z } from "zod"; import { RelativeTime } from "@primer/react"; import { Label } from "../../../lib/components/label"; -import { ListItem } from "../../../lib/components/structured-list"; import { FormSection } from "../../../lib/components/form-section"; -import { StructuredPanel } from "../../../lib/components/structured-panel"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "../../../../components/ui/resizable" -import { VoiceSection } from './components/voice'; -import { ProjectSection } from './components/project'; -import { ToolsSection } from './components/tools'; import { Panel } from "@/components/common/panel-common"; -import { Settings, Wrench, Phone } from "lucide-react"; -import { clsx } from "clsx"; -import { USE_VOICE_FEATURE } from "@/app/lib/feature_flags"; +import { ProjectSection } from './components/project'; export const metadata: Metadata = { title: "Project config", @@ -120,258 +107,6 @@ export function BasicSettingsSection({ ; } -function McpServersSection({ - projectId, -}: { - projectId: string; -}) { - const [servers, setServers] = useState>([]); - const [originalServers, setOriginalServers] = useState>([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); - const { isOpen, onOpen, onClose } = useDisclosure(); - const [newServer, setNewServer] = useState({ name: '', url: '' }); - const [validationErrors, setValidationErrors] = useState<{ - name?: string; - url?: string; - }>({}); - - // Load initial servers - useEffect(() => { - setLoading(true); - getProjectConfig(projectId).then((project) => { - const initialServers = project.mcpServers || []; - setServers(JSON.parse(JSON.stringify(initialServers))); // Deep copy - setOriginalServers(JSON.parse(JSON.stringify(initialServers))); // Deep copy - setLoading(false); - }); - }, [projectId]); - - // Check if there are unsaved changes by comparing the arrays - const hasChanges = useMemo(() => { - if (servers.length !== originalServers.length) return true; - return servers.some((server, index) => { - return server.name !== originalServers[index]?.name || - server.url !== originalServers[index]?.url; - }); - }, [servers, originalServers]); - - const handleAddServer = () => { - setNewServer({ name: '', url: '' }); - setValidationErrors({}); - onOpen(); - }; - - const handleRemoveServer = (index: number) => { - setServers(servers.filter((_, i) => i !== index)); - }; - - const handleCreateServer = () => { - // Clear previous validation errors - setValidationErrors({}); - - const errors: typeof validationErrors = {}; - - // Validate name uniqueness - if (!newServer.name.trim()) { - errors.name = 'Server name is required'; - } else if (servers.some(s => s.name === newServer.name)) { - errors.name = 'Server name must be unique'; - } - - // Validate URL - if (!newServer.url.trim()) { - errors.url = 'Server URL is required'; - } else { - try { - new URL(newServer.url); - } catch { - errors.url = 'Invalid URL format'; - } - } - - if (Object.keys(errors).length > 0) { - setValidationErrors(errors); - return; - } - - setServers([...servers, newServer]); - onClose(); - }; - - const handleSave = async () => { - setSaving(true); - try { - await updateMcpServers(projectId, servers); - setOriginalServers(JSON.parse(JSON.stringify(servers))); // Update original servers after successful save - setMessage({ type: 'success', text: 'Servers updated successfully' }); - setTimeout(() => setMessage(null), 3000); - } catch (error) { - setMessage({ type: 'error', text: 'Failed to update servers' }); - } - setSaving(false); - }; - - return
-
-
-

- MCP servers are used to execute MCP tools. -

- -
- - {loading ? ( - - ) : ( - <> -
- {servers.map((server, index) => ( -
-
-
{server.name}
-
{server.url}
-
- -
- ))} - {servers.length === 0 && ( -
- No servers configured -
- )} -
- - {hasChanges && ( -
- -
- )} - - {message && ( -
- {message.text} -
- )} - - )} - - - - Add MCP Server - -
- { - setNewServer({ ...newServer, name: e.target.value }); - // Clear name error when user types - if (validationErrors.name) { - setValidationErrors(prev => ({ - ...prev, - name: undefined - })); - } - }} - errorMessage={validationErrors.name} - isInvalid={!!validationErrors.name} - isRequired - /> - { - setNewServer({ ...newServer, url: e.target.value }); - // Clear URL error when user types - if (validationErrors.url) { - setValidationErrors(prev => ({ - ...prev, - url: undefined - })); - } - }} - errorMessage={validationErrors.url} - isInvalid={!!validationErrors.url} - isRequired - /> -
-
- - - - -
-
-
-
; -} - -function ApiKeyDisplay({ apiKey }: { apiKey: string }) { - const [isVisible, setIsVisible] = useState(false); - - const formattedKey = isVisible ? apiKey : `${apiKey.slice(0, 2)}${'•'.repeat(5)}${apiKey.slice(-2)}`; - - return ( -
-
{formattedKey}
-
- - { - navigator.clipboard.writeText(apiKey); - }} - label="Copy" - successLabel="Copied" - /> -
-
- ); -} - export function ApiKeysSection({ projectId, }: { @@ -453,7 +188,7 @@ export function ApiKeysSection({ @@ -790,53 +525,34 @@ export function DeleteProjectSection({ ); } -function NavigationMenu({ - selected, - onSelect -}: { - selected: string; - onSelect: (page: string) => void; -}) { - const items = [ - { id: 'Project', icon: }, - { id: 'Tools', icon: }, - ...(USE_VOICE_FEATURE ? [{ id: 'Voice', icon: }] : []) - ]; - +function ApiKeyDisplay({ apiKey }: { apiKey: string }) { + const [isVisible, setIsVisible] = useState(false); + + const formattedKey = isVisible ? apiKey : `${apiKey.slice(0, 2)}${'•'.repeat(5)}${apiKey.slice(-2)}`; + return ( - - - Settings - - } - > -
-
- {items.map((item) => ( -
- -
- ))} -
+
+
{formattedKey}
+
+ + { + navigator.clipboard.writeText(apiKey); + }} + label="Copy" + successLabel="Copied" + />
- +
); } @@ -849,86 +565,26 @@ export function ConfigApp({ useChatWidget: boolean; chatWidgetHost: string; }) { - const [selectedPage, setSelectedPage] = useState('Project'); - - const renderContent = () => { - switch (selectedPage) { - case 'Project': - return ( -
- - - Project Settings -
- } - > -
- -
- -
- ); - case 'Tools': - return ( -
- - - Tools Configuration -
- } - > -
- -
-
- - ); - case 'Voice': - return ( -
- - - Voice Configuration -
- } - > -
- -
- - - ); - default: - return null; - } - }; - return ( - - - - - - - {renderContent()} - - +
+ + + Project Settings +
+ } + > +
+ +
+ + ); } diff --git a/apps/rowboat/app/projects/[projectId]/config/components/project.tsx b/apps/rowboat/app/projects/[projectId]/config/components/project.tsx index 18781eac..a1b84780 100644 --- a/apps/rowboat/app/projects/[projectId]/config/components/project.tsx +++ b/apps/rowboat/app/projects/[projectId]/config/components/project.tsx @@ -416,27 +416,36 @@ function ChatWidgetSection({ projectId, chatWidgetHost }: { projectId: string, c } function DeleteProjectSection({ projectId }: { projectId: string }) { - const [loading, setLoading] = useState(false); + const [loadingInitial, setLoadingInitial] = useState(false); + const [deletingProject, setDeletingProject] = useState(false); const { isOpen, onOpen, onClose } = useDisclosure(); const [projectName, setProjectName] = useState(""); const [projectNameInput, setProjectNameInput] = useState(""); const [confirmationInput, setConfirmationInput] = useState(""); + const [error, setError] = useState(null); const isValid = projectNameInput === projectName && confirmationInput === "delete project"; useEffect(() => { - setLoading(true); + setLoadingInitial(true); getProjectConfig(projectId).then((project) => { setProjectName(project.name); - setLoading(false); + setLoadingInitial(false); }); }, [projectId]); const handleDelete = async () => { if (!isValid) return; - setLoading(true); - await deleteProject(projectId); - setLoading(false); + setError(null); + setDeletingProject(true); + try { + await deleteProject(projectId); + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to delete project"); + setDeletingProject(false); + return; + } + setDeletingProject(false); }; return ( @@ -456,8 +465,7 @@ function DeleteProjectSection({ projectId }: { projectId: string }) { variant="primary" size="sm" onClick={onOpen} - disabled={loading} - isLoading={loading} + disabled={loadingInitial} color="red" > Delete project @@ -483,17 +491,27 @@ function DeleteProjectSection({ projectId }: { projectId: string }) { value={confirmationInput} onChange={(e) => setConfirmationInput(e.target.value)} /> + {error && ( +
+ {error} +
+ )} - diff --git a/apps/rowboat/app/projects/[projectId]/config/components/tools.tsx b/apps/rowboat/app/projects/[projectId]/config/components/tools.tsx deleted file mode 100644 index 9b66b947..00000000 --- a/apps/rowboat/app/projects/[projectId]/config/components/tools.tsx +++ /dev/null @@ -1,316 +0,0 @@ -'use client'; - -import { useState, useEffect, useMemo } from "react"; -import { Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react"; -import { getProjectConfig, updateWebhookUrl } from "../../../../actions/project_actions"; -import { updateMcpServers } from "../../../../actions/mcp_actions"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { PlusIcon } from "lucide-react"; -import { sectionHeaderStyles, sectionDescriptionStyles, inputStyles } from './shared-styles'; -import { clsx } from "clsx"; - -function Section({ title, children, description }: { - title: string; - children: React.ReactNode; - description?: string; -}) { - return ( -
-
-

{title}

- {description && ( -

{description}

- )} -
-
{children}
-
- ); -} - -function McpServersSection({ projectId }: { projectId: string }) { - const [servers, setServers] = useState>([]); - const [originalServers, setOriginalServers] = useState>([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); - const { isOpen, onOpen, onClose } = useDisclosure(); - const [newServer, setNewServer] = useState({ name: '', url: '' }); - const [validationErrors, setValidationErrors] = useState<{ - name?: string; - url?: string; - }>({}); - - useEffect(() => { - setLoading(true); - getProjectConfig(projectId).then((project) => { - const initialServers = project.mcpServers || []; - setServers(JSON.parse(JSON.stringify(initialServers))); - setOriginalServers(JSON.parse(JSON.stringify(initialServers))); - setLoading(false); - }); - }, [projectId]); - - const hasChanges = useMemo(() => { - if (servers.length !== originalServers.length) return true; - return servers.some((server, index) => { - return server.name !== originalServers[index]?.name || - server.url !== originalServers[index]?.url; - }); - }, [servers, originalServers]); - - const handleAddServer = () => { - setNewServer({ name: '', url: '' }); - setValidationErrors({}); - onOpen(); - }; - - const handleRemoveServer = (index: number) => { - setServers(servers.filter((_, i) => i !== index)); - }; - - const handleCreateServer = () => { - setValidationErrors({}); - - const errors: typeof validationErrors = {}; - - if (!newServer.name.trim()) { - errors.name = 'Server name is required'; - } else if (servers.some(s => s.name === newServer.name)) { - errors.name = 'Server name must be unique'; - } - - if (!newServer.url.trim()) { - errors.url = 'Server URL is required'; - } else { - try { - new URL(newServer.url); - } catch { - errors.url = 'Invalid URL format'; - } - } - - if (Object.keys(errors).length > 0) { - setValidationErrors(errors); - return; - } - - setServers([...servers, newServer]); - onClose(); - }; - - const handleSave = async () => { - setSaving(true); - try { - await updateMcpServers(projectId, servers); - setOriginalServers(JSON.parse(JSON.stringify(servers))); - setMessage({ type: 'success', text: 'Servers updated successfully' }); - setTimeout(() => setMessage(null), 3000); - } catch (error) { - setMessage({ type: 'error', text: 'Failed to update servers' }); - } - setSaving(false); - }; - - return
-
-
- -
- - {loading ? ( - - ) : ( - <> -
- {servers.map((server, index) => ( -
-
-
{server.name}
-
{server.url}
-
- -
- ))} - {servers.length === 0 && ( -
- No servers configured -
- )} -
- - {hasChanges && ( -
- -
- )} - - {message && ( -
- {message.text} -
- )} - - )} - - - - Add MCP Server - -
-
- - { - setNewServer({ ...newServer, name: e.target.value }); - if (validationErrors.name) { - setValidationErrors(prev => ({ - ...prev, - name: undefined - })); - } - }} - className={inputStyles} - required - /> - {validationErrors.name && ( -

{validationErrors.name}

- )} -
-
- - { - setNewServer({ ...newServer, url: e.target.value }); - if (validationErrors.url) { - setValidationErrors(prev => ({ - ...prev, - url: undefined - })); - } - }} - className={inputStyles} - required - /> - {validationErrors.url && ( -

{validationErrors.url}

- )} -
-
-
- - - - -
-
-
-
; -} - -export function WebhookUrlSection({ projectId }: { projectId: string }) { - const [loading, setLoading] = useState(false); - const [webhookUrl, setWebhookUrl] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - setLoading(true); - getProjectConfig(projectId).then((project) => { - setWebhookUrl(project.webhookUrl || null); - setLoading(false); - }); - }, [projectId]); - - function validate(url: string) { - try { - new URL(url); - setError(null); - return { valid: true }; - } catch { - setError('Please enter a valid URL'); - return { valid: false, errorMessage: 'Please enter a valid URL' }; - } - } - - return
-
-
-