diff --git a/apps/docs/docs/add_tools.md b/apps/docs/docs/add_tools.md index f88ce710..0fc7e6e8 100644 --- a/apps/docs/docs/add_tools.md +++ b/apps/docs/docs/add_tools.md @@ -1,16 +1,28 @@ ## Add tools to agents -Copilot can help you add tools to agents. You can (a) add a mock tool, (b) add a tool from an MCP server, (c) integrate with you own tools using a webhook. - - -### Adding mock tools -You can mock any tool you have created by checking the 'Mock tool responses' option. - - -![Example Tool](img/mock-tool.png) +In Rowboat, you can add tools to your agents by (a) selecting from a in-built library of MCP tools (b) adding your own customer MCP servers (c) integrating your APIs through a webhook (e) mocking tool calls to test the system. ### Adding MCP tools -You can add a running MCP server in Settings -> Tools. +#### Hosted MCP Library + +Rowboat has partnered with ![Kavis AI](https://www.klavis.ai/) to provide a growing library of hosted MCP servers. You can obtain a 'KLAVIS_API_KEY' and add it to your env for the library to show up automatically under the tools section. + +![Library](img/mcp-library.png) + +Enable any of the hosted MCP servers by clicking on the enable button. The server will take approximately 10 seconds to spin up. + +![Library](img/enable-mcp-server.png) + +For most servers, you will need to authorize it by clicking on the 'Auth' button and connecting to your account e.g. connecting to you github or slack account + +The servers you have enabled will show up under tools section in the build view and can be added to any of the agents. + +![Library](img/mcp-tools-build-view.png) + +Note: For GSuite tools, you need to get the google client ID from your GSuite account and set it to the env variable 'KLAVIS_GOOGLE_CLIENT_ID'. + +#### Custom MCP Server +You can add any running MCP server in Settings -> Tools. ![Example Tool](img/add-mcp-server.png) @@ -20,6 +32,11 @@ Now, you can import the tools from the MCP server in the Build view. ![Example Tool](img/import-mcp-tools.png) +### Adding mock tools +You can mock any tool you have created by checking the 'Mock tool responses' option. + + +![Example Tool](img/mock-tool.png) ### Debug tool calls in the playground When agents call tools during a chat in the playground, the tool call parameters and response are available for debugging real-time. For testing purposes, the platform can produce mock tool responses in the playground, without integrating actual tools. diff --git a/apps/docs/docs/img/enable-mcp-server.png b/apps/docs/docs/img/enable-mcp-server.png new file mode 100644 index 00000000..b7138ebc Binary files /dev/null and b/apps/docs/docs/img/enable-mcp-server.png differ diff --git a/apps/docs/docs/img/mcp-library.png b/apps/docs/docs/img/mcp-library.png new file mode 100644 index 00000000..970193f6 Binary files /dev/null and b/apps/docs/docs/img/mcp-library.png differ diff --git a/apps/docs/docs/img/mcp-tools-build-view.png b/apps/docs/docs/img/mcp-tools-build-view.png new file mode 100644 index 00000000..30d1f2cd Binary files /dev/null and b/apps/docs/docs/img/mcp-tools-build-view.png differ 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..c8ea00b9 --- /dev/null +++ b/apps/rowboat/app/actions/klavis_actions.ts @@ -0,0 +1,844 @@ +'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', + 'Gmail': 'gmail', +}; + +// 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, + 'Gmail': 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
-
-
-