improvement(mcp): restructure mcp tools caching/fetching info to improve UX (#2416)

* feat(mcp): improve cache practice

* restructure mcps fetching, caching, UX indicators

* fix schema

* styling improvements

* fix tooltips and render issue

* fix loading sequence + add redis

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Vikhyath Mondreti
2025-12-16 21:23:18 -08:00
committed by GitHub
parent b7228d57f7
commit de330d80f5
31 changed files with 9087 additions and 337 deletions

View File

@@ -5,6 +5,7 @@ import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { withMcpAuth } from '@/lib/mcp/middleware' import { withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service' import { mcpService } from '@/lib/mcp/service'
import type { McpServerStatusConfig } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpServerRefreshAPI') const logger = createLogger('McpServerRefreshAPI')
@@ -50,6 +51,12 @@ export const POST = withMcpAuth<{ id: string }>('read')(
let toolCount = 0 let toolCount = 0
let lastError: string | null = null let lastError: string | null = null
const currentStatusConfig: McpServerStatusConfig =
(server.statusConfig as McpServerStatusConfig | null) ?? {
consecutiveFailures: 0,
lastSuccessfulDiscovery: null,
}
try { try {
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
connectionStatus = 'connected' connectionStatus = 'connected'
@@ -63,20 +70,40 @@ export const POST = withMcpAuth<{ id: string }>('read')(
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error) logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
} }
const now = new Date()
const newStatusConfig =
connectionStatus === 'connected'
? { consecutiveFailures: 0, lastSuccessfulDiscovery: now.toISOString() }
: {
consecutiveFailures: currentStatusConfig.consecutiveFailures + 1,
lastSuccessfulDiscovery: currentStatusConfig.lastSuccessfulDiscovery,
}
const [refreshedServer] = await db const [refreshedServer] = await db
.update(mcpServers) .update(mcpServers)
.set({ .set({
lastToolsRefresh: new Date(), lastToolsRefresh: now,
connectionStatus, connectionStatus,
lastError, lastError,
lastConnected: connectionStatus === 'connected' ? new Date() : server.lastConnected, lastConnected: connectionStatus === 'connected' ? now : server.lastConnected,
toolCount, toolCount,
updatedAt: new Date(), statusConfig: newStatusConfig,
updatedAt: now,
}) })
.where(eq(mcpServers.id, serverId)) .where(eq(mcpServers.id, serverId))
.returning() .returning()
logger.info(`[${requestId}] Successfully refreshed MCP server: ${serverId}`) if (connectionStatus === 'connected') {
logger.info(
`[${requestId}] Successfully refreshed MCP server: ${serverId} (${toolCount} tools)`
)
await mcpService.clearCache(workspaceId)
} else {
logger.warn(
`[${requestId}] Refresh completed for MCP server ${serverId} but connection failed: ${lastError}`
)
}
return createMcpSuccessResponse({ return createMcpSuccessResponse({
status: connectionStatus, status: connectionStatus,
toolCount, toolCount,

View File

@@ -48,6 +48,19 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
// Remove workspaceId from body to prevent it from being updated // Remove workspaceId from body to prevent it from being updated
const { workspaceId: _, ...updateData } = body const { workspaceId: _, ...updateData } = body
// Get the current server to check if URL is changing
const [currentServer] = await db
.select({ url: mcpServers.url })
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
const [updatedServer] = await db const [updatedServer] = await db
.update(mcpServers) .update(mcpServers)
.set({ .set({
@@ -71,8 +84,12 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
) )
} }
// Clear MCP service cache after update // Only clear cache if URL changed (requires re-discovery)
mcpService.clearCache(workspaceId) const urlChanged = body.url && currentServer?.url !== body.url
if (urlChanged) {
await mcpService.clearCache(workspaceId)
logger.info(`[${requestId}] Cleared cache due to URL change`)
}
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
return createMcpSuccessResponse({ server: updatedServer }) return createMcpSuccessResponse({ server: updatedServer })

View File

@@ -117,12 +117,14 @@ export const POST = withMcpAuth('write')(
timeout: body.timeout || 30000, timeout: body.timeout || 30000,
retries: body.retries || 3, retries: body.retries || 3,
enabled: body.enabled !== false, enabled: body.enabled !== false,
connectionStatus: 'connected',
lastConnected: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
}) })
.where(eq(mcpServers.id, serverId)) .where(eq(mcpServers.id, serverId))
mcpService.clearCache(workspaceId) await mcpService.clearCache(workspaceId)
logger.info( logger.info(
`[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})` `[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})`
@@ -145,12 +147,14 @@ export const POST = withMcpAuth('write')(
timeout: body.timeout || 30000, timeout: body.timeout || 30000,
retries: body.retries || 3, retries: body.retries || 3,
enabled: body.enabled !== false, enabled: body.enabled !== false,
connectionStatus: 'connected',
lastConnected: new Date(),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.returning() .returning()
mcpService.clearCache(workspaceId) await mcpService.clearCache(workspaceId)
logger.info( logger.info(
`[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})` `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})`
@@ -212,7 +216,7 @@ export const DELETE = withMcpAuth('admin')(
) )
} }
mcpService.clearCache(workspaceId) await mcpService.clearCache(workspaceId)
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })

View File

@@ -0,0 +1,103 @@
import { db } from '@sim/db'
import { workflow, workflowBlocks } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { withMcpAuth } from '@/lib/mcp/middleware'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpStoredToolsAPI')
export const dynamic = 'force-dynamic'
interface StoredMcpTool {
workflowId: string
workflowName: string
serverId: string
serverUrl?: string
toolName: string
schema?: Record<string, unknown>
}
/**
* GET - Get all stored MCP tools from workflows in the workspace
*
* Scans all workflows in the workspace and extracts MCP tools that have been
* added to agent blocks. Returns the stored state of each tool for comparison
* against current server state.
*/
export const GET = withMcpAuth('read')(
async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`)
// Get all workflows in workspace
const workflows = await db
.select({
id: workflow.id,
name: workflow.name,
})
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
const workflowMap = new Map(workflows.map((w) => [w.id, w.name]))
const workflowIds = workflows.map((w) => w.id)
if (workflowIds.length === 0) {
return createMcpSuccessResponse({ tools: [] })
}
// Get all agent blocks from these workflows
const agentBlocks = await db
.select({
workflowId: workflowBlocks.workflowId,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.type, 'agent'))
const storedTools: StoredMcpTool[] = []
for (const block of agentBlocks) {
if (!workflowMap.has(block.workflowId)) continue
const subBlocks = block.subBlocks as Record<string, unknown> | null
if (!subBlocks) continue
const toolsSubBlock = subBlocks.tools as Record<string, unknown> | undefined
const toolsValue = toolsSubBlock?.value
if (!toolsValue || !Array.isArray(toolsValue)) continue
for (const tool of toolsValue) {
if (tool.type !== 'mcp') continue
const params = tool.params as Record<string, unknown> | undefined
if (!params?.serverId || !params?.toolName) continue
storedTools.push({
workflowId: block.workflowId,
workflowName: workflowMap.get(block.workflowId) || 'Untitled',
serverId: params.serverId as string,
serverUrl: params.serverUrl as string | undefined,
toolName: params.toolName as string,
schema: tool.schema as Record<string, unknown> | undefined,
})
}
}
logger.info(
`[${requestId}] Found ${storedTools.length} stored MCP tools across ${workflows.length} workflows`
)
return createMcpSuccessResponse({ tools: storedTools })
} catch (error) {
logger.error(`[${requestId}] Error fetching stored MCP tools:`, error)
return createMcpErrorResponse(
error instanceof Error ? error : new Error('Failed to fetch stored MCP tools'),
'Failed to fetch stored MCP tools',
500
)
}
}
)

View File

@@ -18,12 +18,18 @@ interface McpTool {
inputSchema?: any inputSchema?: any
} }
interface McpServer {
id: string
url?: string
}
interface StoredTool { interface StoredTool {
type: 'mcp' type: 'mcp'
title: string title: string
toolId: string toolId: string
params: { params: {
serverId: string serverId: string
serverUrl?: string
toolName: string toolName: string
serverName: string serverName: string
} }
@@ -34,6 +40,7 @@ interface StoredTool {
interface McpToolsListProps { interface McpToolsListProps {
mcpTools: McpTool[] mcpTools: McpTool[]
mcpServers?: McpServer[]
searchQuery: string searchQuery: string
customFilter: (name: string, query: string) => number customFilter: (name: string, query: string) => number
onToolSelect: (tool: StoredTool) => void onToolSelect: (tool: StoredTool) => void
@@ -45,6 +52,7 @@ interface McpToolsListProps {
*/ */
export function McpToolsList({ export function McpToolsList({
mcpTools, mcpTools,
mcpServers = [],
searchQuery, searchQuery,
customFilter, customFilter,
onToolSelect, onToolSelect,
@@ -59,44 +67,48 @@ export function McpToolsList({
return ( return (
<> <>
<PopoverSection>MCP Tools</PopoverSection> <PopoverSection>MCP Tools</PopoverSection>
{filteredTools.map((mcpTool) => ( {filteredTools.map((mcpTool) => {
<ToolCommand.Item const server = mcpServers.find((s) => s.id === mcpTool.serverId)
key={mcpTool.id} return (
value={mcpTool.name} <ToolCommand.Item
onSelect={() => { key={mcpTool.id}
if (disabled) return value={mcpTool.name}
onSelect={() => {
if (disabled) return
const newTool: StoredTool = { const newTool: StoredTool = {
type: 'mcp', type: 'mcp',
title: mcpTool.name, title: mcpTool.name,
toolId: mcpTool.id, toolId: mcpTool.id,
params: { params: {
serverId: mcpTool.serverId, serverId: mcpTool.serverId,
toolName: mcpTool.name, serverUrl: server?.url,
serverName: mcpTool.serverName, toolName: mcpTool.name,
}, serverName: mcpTool.serverName,
isExpanded: true, },
usageControl: 'auto', isExpanded: true,
schema: { usageControl: 'auto',
...mcpTool.inputSchema, schema: {
description: mcpTool.description, ...mcpTool.inputSchema,
}, description: mcpTool.description,
} },
}
onToolSelect(newTool) onToolSelect(newTool)
}} }}
>
<div
className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded'
style={{ background: mcpTool.bgColor }}
> >
<IconComponent icon={mcpTool.icon} className='h-[11px] w-[11px] text-white' /> <div
</div> className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded'
<span className='truncate' title={`${mcpTool.name} (${mcpTool.serverName})`}> style={{ background: mcpTool.bgColor }}
{mcpTool.name} >
</span> <IconComponent icon={mcpTool.icon} className='h-[11px] w-[11px] text-white' />
</ToolCommand.Item> </div>
))} <span className='truncate' title={`${mcpTool.name} (${mcpTool.serverName})`}>
{mcpTool.name}
</span>
</ToolCommand.Item>
)
})}
</> </>
) )
} }

View File

@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'
import { Loader2, PlusIcon, WrenchIcon, XIcon } from 'lucide-react' import { Loader2, PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { import {
Badge,
Combobox, Combobox,
Popover, Popover,
PopoverContent, PopoverContent,
@@ -12,6 +13,7 @@ import {
PopoverSearch, PopoverSearch,
PopoverSection, PopoverSection,
PopoverTrigger, PopoverTrigger,
Tooltip,
} from '@/components/emcn' } from '@/components/emcn'
import { McpIcon } from '@/components/icons' import { McpIcon } from '@/components/icons'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
@@ -55,9 +57,11 @@ import {
type CustomTool as CustomToolDefinition, type CustomTool as CustomToolDefinition,
useCustomTools, useCustomTools,
} from '@/hooks/queries/custom-tools' } from '@/hooks/queries/custom-tools'
import { useMcpServers } from '@/hooks/queries/mcp'
import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkflows } from '@/hooks/queries/workflows'
import { useMcpTools } from '@/hooks/use-mcp-tools' import { useMcpTools } from '@/hooks/use-mcp-tools'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils' import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
import { useSettingsModalStore } from '@/stores/settings-modal/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { import {
formatParameterLabel, formatParameterLabel,
@@ -802,6 +806,66 @@ export function ToolInput({
refreshTools, refreshTools,
} = useMcpTools(workspaceId) } = useMcpTools(workspaceId)
const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId)
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
const mcpDataLoading = mcpLoading || mcpServersLoading
/**
* Returns issue info for an MCP tool using shared validation logic.
*/
const getMcpToolIssue = useCallback(
(tool: StoredTool) => {
if (tool.type !== 'mcp') return null
const { getMcpToolIssue: validateTool } = require('@/lib/mcp/tool-validation')
return validateTool(
{
serverId: tool.params?.serverId as string,
serverUrl: tool.params?.serverUrl as string | undefined,
toolName: tool.params?.toolName as string,
schema: tool.schema,
},
mcpServers.map((s) => ({
id: s.id,
url: s.url,
connectionStatus: s.connectionStatus,
lastError: s.lastError,
})),
mcpTools.map((t) => ({
serverId: t.serverId,
name: t.name,
inputSchema: t.inputSchema,
}))
)
},
[mcpTools, mcpServers]
)
const isMcpToolUnavailable = useCallback(
(tool: StoredTool): boolean => {
const { isToolUnavailable } = require('@/lib/mcp/tool-validation')
return isToolUnavailable(getMcpToolIssue(tool))
},
[getMcpToolIssue]
)
const hasMcpToolIssue = useCallback(
(tool: StoredTool): boolean => {
return getMcpToolIssue(tool) !== null
},
[getMcpToolIssue]
)
// Filter out MCP tools from unavailable servers for the dropdown
const availableMcpTools = useMemo(() => {
return mcpTools.filter((mcpTool) => {
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
// Only include tools from connected servers
return server && server.connectionStatus === 'connected'
})
}, [mcpTools, mcpServers])
// Reset search query when popover opens // Reset search query when popover opens
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -1849,9 +1913,10 @@ export function ToolInput({
) )
})()} })()}
{/* Display MCP tools */} {/* Display MCP tools (only from available servers) */}
<McpToolsList <McpToolsList
mcpTools={mcpTools} mcpTools={availableMcpTools}
mcpServers={mcpServers}
searchQuery={searchQuery || ''} searchQuery={searchQuery || ''}
customFilter={customFilter} customFilter={customFilter}
onToolSelect={handleMcpToolSelect} onToolSelect={handleMcpToolSelect}
@@ -2040,9 +2105,46 @@ export function ToolInput({
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'> <span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{isCustomTool ? customToolTitle : tool.title} {isCustomTool ? customToolTitle : tool.title}
</span> </span>
{isMcpTool &&
!mcpDataLoading &&
(() => {
const issue = getMcpToolIssue(tool)
if (!issue) return null
const { getIssueBadgeLabel } = require('@/lib/mcp/tool-validation')
const serverId = tool.params?.serverId
return (
<div
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
openSettingsModal({ section: 'mcp', mcpServerId: serverId })
}}
>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='outline'
className='cursor-pointer transition-colors hover:bg-[var(--warning)]/10'
style={{
borderColor: 'var(--warning)',
color: 'var(--warning)',
}}
>
{getIssueBadgeLabel(issue)}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<span className='text-sm'>
{issue.message} · Click to open settings
</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
)
})()}
</div> </div>
<div className='flex flex-shrink-0 items-center gap-[8px]'> <div className='flex flex-shrink-0 items-center gap-[8px]'>
{supportsToolControl && ( {supportsToolControl && !(isMcpTool && isMcpToolUnavailable(tool)) && (
<Popover <Popover
open={usageControlPopoverIndex === toolIndex} open={usageControlPopoverIndex === toolIndex}
onOpenChange={(open) => onOpenChange={(open) =>
@@ -2386,9 +2488,10 @@ export function ToolInput({
) )
})()} })()}
{/* Display MCP tools */} {/* Display MCP tools (only from available servers) */}
<McpToolsList <McpToolsList
mcpTools={mcpTools} mcpTools={availableMcpTools}
mcpServers={mcpServers}
searchQuery={searchQuery || ''} searchQuery={searchQuery || ''}
customFilter={customFilter} customFilter={customFilter}
onToolSelect={handleMcpToolSelect} onToolSelect={handleMcpToolSelect}

View File

@@ -1,8 +1,5 @@
import { Button } from '@/components/emcn' import { Button } from '@/components/emcn'
/**
* Formats transport type for display (e.g., "streamable-http" -> "Streamable-HTTP").
*/
export function formatTransportLabel(transport: string): string { export function formatTransportLabel(transport: string): string {
return transport return transport
.split('-') .split('-')
@@ -14,10 +11,10 @@ export function formatTransportLabel(transport: string): string {
.join('-') .join('-')
} }
/** function formatToolsLabel(tools: any[], connectionStatus?: string): string {
* Formats tools count and names for display. if (connectionStatus === 'error') {
*/ return 'Unable to connect'
function formatToolsLabel(tools: any[]): string { }
const count = tools.length const count = tools.length
const plural = count !== 1 ? 's' : '' const plural = count !== 1 ? 's' : ''
const names = count > 0 ? `: ${tools.map((t) => t.name).join(', ')}` : '' const names = count > 0 ? `: ${tools.map((t) => t.name).join(', ')}` : ''
@@ -29,35 +26,41 @@ interface ServerListItemProps {
tools: any[] tools: any[]
isDeleting: boolean isDeleting: boolean
isLoadingTools?: boolean isLoadingTools?: boolean
isRefreshing?: boolean
onRemove: () => void onRemove: () => void
onViewDetails: () => void onViewDetails: () => void
} }
/**
* Renders a single MCP server list item with details and delete actions.
*/
export function ServerListItem({ export function ServerListItem({
server, server,
tools, tools,
isDeleting, isDeleting,
isLoadingTools = false, isLoadingTools = false,
isRefreshing = false,
onRemove, onRemove,
onViewDetails, onViewDetails,
}: ServerListItemProps) { }: ServerListItemProps) {
const transportLabel = formatTransportLabel(server.transport || 'http') const transportLabel = formatTransportLabel(server.transport || 'http')
const toolsLabel = formatToolsLabel(tools) const toolsLabel = formatToolsLabel(tools, server.connectionStatus)
const isError = server.connectionStatus === 'error'
return ( return (
<div className='flex items-center justify-between gap-[12px]'> <div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'> <div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'> <div className='flex items-center gap-[6px]'>
<span className='max-w-[280px] truncate font-medium text-[14px]'> <span className='max-w-[200px] truncate font-medium text-[14px]'>
{server.name || 'Unnamed Server'} {server.name || 'Unnamed Server'}
</span> </span>
<span className='text-[13px] text-[var(--text-secondary)]'>({transportLabel})</span> <span className='text-[13px] text-[var(--text-secondary)]'>({transportLabel})</span>
</div> </div>
<p className='truncate text-[13px] text-[var(--text-muted)]'> <p
{isLoadingTools && tools.length === 0 ? 'Loading...' : toolsLabel} className={`truncate text-[13px] ${isError ? 'text-red-500 dark:text-red-400' : 'text-[var(--text-muted)]'}`}
>
{isRefreshing
? 'Refreshing...'
: isLoadingTools && tools.length === 0
? 'Loading...'
: toolsLabel}
</p> </p>
</div> </div>
<div className='flex flex-shrink-0 items-center gap-[4px]'> <div className='flex flex-shrink-0 items-center gap-[4px]'>

View File

@@ -1,9 +1,10 @@
'use client' 'use client'
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Plus, Search } from 'lucide-react' import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { import {
Badge,
Button, Button,
Input as EmcnInput, Input as EmcnInput,
Modal, Modal,
@@ -14,6 +15,7 @@ import {
} from '@/components/emcn' } from '@/components/emcn'
import { Input } from '@/components/ui' import { Input } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { getIssueBadgeLabel, getMcpToolIssue, type McpToolIssue } from '@/lib/mcp/tool-validation'
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { import {
useCreateMcpServer, useCreateMcpServer,
@@ -21,6 +23,7 @@ import {
useMcpServers, useMcpServers,
useMcpToolsQuery, useMcpToolsQuery,
useRefreshMcpServer, useRefreshMcpServer,
useStoredMcpTools,
} from '@/hooks/queries/mcp' } from '@/hooks/queries/mcp'
import { useMcpServerTest } from '@/hooks/use-mcp-server-test' import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
import type { InputFieldType, McpServerFormData, McpServerTestResult } from './components' import type { InputFieldType, McpServerFormData, McpServerTestResult } from './components'
@@ -44,6 +47,9 @@ interface McpServer {
name?: string name?: string
transport?: string transport?: string
url?: string url?: string
connectionStatus?: 'connected' | 'disconnected' | 'error'
lastError?: string | null
lastConnected?: string
} }
const logger = createLogger('McpSettings') const logger = createLogger('McpSettings')
@@ -69,11 +75,15 @@ function getTestButtonLabel(
return 'Test Connection' return 'Test Connection'
} }
interface MCPProps {
initialServerId?: string | null
}
/** /**
* MCP Settings component for managing Model Context Protocol servers. * MCP Settings component for managing Model Context Protocol servers.
* Handles server CRUD operations, connection testing, and environment variable integration. * Handles server CRUD operations, connection testing, and environment variable integration.
*/ */
export function MCP() { export function MCP({ initialServerId }: MCPProps) {
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
@@ -88,6 +98,7 @@ export function MCP() {
isLoading: toolsLoading, isLoading: toolsLoading,
isFetching: toolsFetching, isFetching: toolsFetching,
} = useMcpToolsQuery(workspaceId) } = useMcpToolsQuery(workspaceId)
const { data: storedTools = [] } = useStoredMcpTools(workspaceId)
const createServerMutation = useCreateMcpServer() const createServerMutation = useCreateMcpServer()
const deleteServerMutation = useDeleteMcpServer() const deleteServerMutation = useDeleteMcpServer()
const refreshServerMutation = useRefreshMcpServer() const refreshServerMutation = useRefreshMcpServer()
@@ -106,7 +117,9 @@ export function MCP() {
const [serverToDelete, setServerToDelete] = useState<{ id: string; name: string } | null>(null) const [serverToDelete, setServerToDelete] = useState<{ id: string; name: string } | null>(null)
const [selectedServerId, setSelectedServerId] = useState<string | null>(null) const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
const [refreshStatus, setRefreshStatus] = useState<'idle' | 'refreshing' | 'refreshed'>('idle') const [refreshingServers, setRefreshingServers] = useState<
Record<string, 'refreshing' | 'refreshed'>
>({})
const [showEnvVars, setShowEnvVars] = useState(false) const [showEnvVars, setShowEnvVars] = useState(false)
const [envSearchTerm, setEnvSearchTerm] = useState('') const [envSearchTerm, setEnvSearchTerm] = useState('')
@@ -114,10 +127,16 @@ export function MCP() {
const [activeInputField, setActiveInputField] = useState<InputFieldType | null>(null) const [activeInputField, setActiveInputField] = useState<InputFieldType | null>(null)
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null) const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
// Scroll position state for formatted text overlays
const [urlScrollLeft, setUrlScrollLeft] = useState(0) const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({}) const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
// Auto-select server when initialServerId is provided
useEffect(() => {
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
setSelectedServerId(initialServerId)
}
}, [initialServerId, servers])
/** /**
* Resets environment variable dropdown state. * Resets environment variable dropdown state.
*/ */
@@ -237,6 +256,7 @@ export function MCP() {
/** /**
* Adds a new MCP server after validating and testing the connection. * Adds a new MCP server after validating and testing the connection.
* Only creates the server if connection test succeeds.
*/ */
const handleAddServer = useCallback(async () => { const handleAddServer = useCallback(async () => {
if (!formData.name.trim()) return if (!formData.name.trim()) return
@@ -253,12 +273,12 @@ export function MCP() {
workspaceId, workspaceId,
} }
if (!testResult) { const connectionResult = await testConnection(serverConfig)
const result = await testConnection(serverConfig)
if (!result.success) return
}
if (testResult && !testResult.success) return if (!connectionResult.success) {
logger.error('Connection test failed, server not added:', connectionResult.error)
return
}
await createServerMutation.mutateAsync({ await createServerMutation.mutateAsync({
workspaceId, workspaceId,
@@ -279,15 +299,7 @@ export function MCP() {
} finally { } finally {
setIsAddingServer(false) setIsAddingServer(false)
} }
}, [ }, [formData, testConnection, createServerMutation, workspaceId, headersToRecord, resetForm])
formData,
testResult,
testConnection,
createServerMutation,
workspaceId,
headersToRecord,
resetForm,
])
/** /**
* Opens the delete confirmation dialog for an MCP server. * Opens the delete confirmation dialog for an MCP server.
@@ -297,9 +309,6 @@ export function MCP() {
setShowDeleteDialog(true) setShowDeleteDialog(true)
}, []) }, [])
/**
* Confirms and executes the server deletion.
*/
const confirmDeleteServer = useCallback(async () => { const confirmDeleteServer = useCallback(async () => {
if (!serverToDelete) return if (!serverToDelete) return
@@ -399,14 +408,24 @@ export function MCP() {
const handleRefreshServer = useCallback( const handleRefreshServer = useCallback(
async (serverId: string) => { async (serverId: string) => {
try { try {
setRefreshStatus('refreshing') setRefreshingServers((prev) => ({ ...prev, [serverId]: 'refreshing' }))
await refreshServerMutation.mutateAsync({ workspaceId, serverId }) await refreshServerMutation.mutateAsync({ workspaceId, serverId })
logger.info(`Refreshed MCP server: ${serverId}`) logger.info(`Refreshed MCP server: ${serverId}`)
setRefreshStatus('refreshed') setRefreshingServers((prev) => ({ ...prev, [serverId]: 'refreshed' }))
setTimeout(() => setRefreshStatus('idle'), 2000) setTimeout(() => {
setRefreshingServers((prev) => {
const newState = { ...prev }
delete newState[serverId]
return newState
})
}, 2000)
} catch (error) { } catch (error) {
logger.error('Failed to refresh MCP server:', error) logger.error('Failed to refresh MCP server:', error)
setRefreshStatus('idle') setRefreshingServers((prev) => {
const newState = { ...prev }
delete newState[serverId]
return newState
})
} }
}, },
[refreshServerMutation, workspaceId] [refreshServerMutation, workspaceId]
@@ -432,6 +451,53 @@ export function MCP() {
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection) const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
/**
* Gets issues for stored tools that reference a specific server tool.
* Returns issues from all workflows that have stored this tool.
*/
const getStoredToolIssues = useCallback(
(serverId: string, toolName: string): { issue: McpToolIssue; workflowName: string }[] => {
const relevantStoredTools = storedTools.filter(
(st) => st.serverId === serverId && st.toolName === toolName
)
const serverStates = servers.map((s) => ({
id: s.id,
url: s.url,
connectionStatus: s.connectionStatus,
lastError: s.lastError || undefined,
}))
const discoveredTools = mcpToolsData.map((t) => ({
serverId: t.serverId,
name: t.name,
inputSchema: t.inputSchema,
}))
const issues: { issue: McpToolIssue; workflowName: string }[] = []
for (const storedTool of relevantStoredTools) {
const issue = getMcpToolIssue(
{
serverId: storedTool.serverId,
serverUrl: storedTool.serverUrl,
toolName: storedTool.toolName,
schema: storedTool.schema,
},
serverStates,
discoveredTools
)
if (issue) {
issues.push({ issue, workflowName: storedTool.workflowName })
}
}
return issues
},
[storedTools, servers, mcpToolsData]
)
if (selectedServer) { if (selectedServer) {
const { server, tools } = selectedServer const { server, tools } = selectedServer
const transportLabel = formatTransportLabel(server.transport || 'http') const transportLabel = formatTransportLabel(server.transport || 'http')
@@ -463,6 +529,15 @@ export function MCP() {
</div> </div>
)} )}
{server.connectionStatus === 'error' && (
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Status</span>
<p className='text-[14px] text-red-500 dark:text-red-400'>
{server.lastError || 'Unable to connect'}
</p>
</div>
)}
<div className='flex flex-col gap-[8px]'> <div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'> <span className='font-medium text-[13px] text-[var(--text-primary)]'>
Tools ({tools.length}) Tools ({tools.length})
@@ -471,21 +546,37 @@ export function MCP() {
<p className='text-[13px] text-[var(--text-muted)]'>No tools available</p> <p className='text-[13px] text-[var(--text-muted)]'>No tools available</p>
) : ( ) : (
<div className='flex flex-col gap-[8px]'> <div className='flex flex-col gap-[8px]'>
{tools.map((tool) => ( {tools.map((tool) => {
<div const issues = getStoredToolIssues(server.id, tool.name)
key={tool.name} return (
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]' <div
> key={tool.name}
<p className='font-medium text-[13px] text-[var(--text-primary)]'> className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
{tool.name} >
</p> <div className='flex items-center justify-between'>
{tool.description && ( <p className='font-medium text-[13px] text-[var(--text-primary)]'>
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'> {tool.name}
{tool.description} </p>
</p> {issues.length > 0 && (
)} <Badge
</div> variant='outline'
))} style={{
borderColor: 'var(--warning)',
color: 'var(--warning)',
}}
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
)}
</div>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
)}
</div>
)
})}
</div> </div>
)} )}
</div> </div>
@@ -496,11 +587,11 @@ export function MCP() {
<Button <Button
onClick={() => handleRefreshServer(server.id)} onClick={() => handleRefreshServer(server.id)}
variant='default' variant='default'
disabled={refreshStatus !== 'idle'} disabled={!!refreshingServers[server.id]}
> >
{refreshStatus === 'refreshing' {refreshingServers[server.id] === 'refreshing'
? 'Refreshing...' ? 'Refreshing...'
: refreshStatus === 'refreshed' : refreshingServers[server.id] === 'refreshed'
? 'Refreshed' ? 'Refreshed'
: 'Refresh Tools'} : 'Refresh Tools'}
</Button> </Button>
@@ -672,6 +763,7 @@ export function MCP() {
tools={tools} tools={tools}
isDeleting={deletingServers.has(server.id)} isDeleting={deletingServers.has(server.id)}
isLoadingTools={isLoadingTools} isLoadingTools={isLoadingTools}
isRefreshing={refreshingServers[server.id] === 'refreshing'}
onRemove={() => handleRemoveServer(server.id, server.name || 'this server')} onRemove={() => handleRemoveServer(server.id, server.name || 'this server')}
onViewDetails={() => handleViewDetails(server.id)} onViewDetails={() => handleViewDetails(server.id)}
/> />

View File

@@ -46,6 +46,7 @@ import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization' import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso' import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription' import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { useSettingsModalStore } from '@/stores/settings-modal/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
@@ -134,6 +135,8 @@ const allNavigationItems: NavigationItem[] = [
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general') const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
const { data: session } = useSession() const { data: session } = useSession()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: organizationsData } = useOrganizations() const { data: organizationsData } = useOrganizations()
@@ -247,6 +250,24 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
// React Query hook automatically loads and syncs settings // React Query hook automatically loads and syncs settings
useGeneralSettings() useGeneralSettings()
// Apply initial section from store when modal opens
useEffect(() => {
if (open && initialSection) {
setActiveSection(initialSection)
if (mcpServerId) {
setPendingMcpServerId(mcpServerId)
}
clearInitialState()
}
}, [open, initialSection, mcpServerId, clearInitialState])
// Clear pending server ID when section changes away from MCP
useEffect(() => {
if (activeSection !== 'mcp') {
setPendingMcpServerId(null)
}
}, [activeSection])
useEffect(() => { useEffect(() => {
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => { const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
setActiveSection(event.detail.tab) setActiveSection(event.detail.tab)
@@ -436,7 +457,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{isBillingEnabled && activeSection === 'team' && <TeamManagement />} {isBillingEnabled && activeSection === 'team' && <TeamManagement />}
{activeSection === 'sso' && <SSO />} {activeSection === 'sso' && <SSO />}
{activeSection === 'copilot' && <Copilot />} {activeSection === 'copilot' && <Copilot />}
{activeSection === 'mcp' && <MCP />} {activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{activeSection === 'custom-tools' && <CustomTools />} {activeSection === 'custom-tools' && <CustomTools />}
</SModalMainBody> </SModalMainBody>
</SModalMain> </SModalMain>

View File

@@ -32,6 +32,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/hooks' } from '@/app/workspace/[workspaceId]/w/hooks'
import { useFolderStore } from '@/stores/folders/store' import { useFolderStore } from '@/stores/folders/store'
import { useSearchModalStore } from '@/stores/search-modal/store' import { useSearchModalStore } from '@/stores/search-modal/store'
import { useSettingsModalStore } from '@/stores/settings-modal/store'
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store' import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
const logger = createLogger('Sidebar') const logger = createLogger('Sidebar')
@@ -88,7 +89,11 @@ export function Sidebar() {
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false) const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false) const {
isOpen: isSettingsModalOpen,
openModal: openSettingsModal,
closeModal: closeSettingsModal,
} = useSettingsModalStore()
/** Listens for external events to open help modal */ /** Listens for external events to open help modal */
useEffect(() => { useEffect(() => {
@@ -219,7 +224,7 @@ export function Sidebar() {
id: 'settings', id: 'settings',
label: 'Settings', label: 'Settings',
icon: Settings, icon: Settings,
onClick: () => setIsSettingsModalOpen(true), onClick: () => openSettingsModal(),
}, },
], ],
[workspaceId] [workspaceId]
@@ -654,7 +659,10 @@ export function Sidebar() {
{/* Footer Navigation Modals */} {/* Footer Navigation Modals */}
<HelpModal open={isHelpModalOpen} onOpenChange={setIsHelpModalOpen} /> <HelpModal open={isHelpModalOpen} onOpenChange={setIsHelpModalOpen} />
<SettingsModal open={isSettingsModalOpen} onOpenChange={setIsSettingsModalOpen} /> <SettingsModal
open={isSettingsModalOpen}
onOpenChange={(open) => (open ? openSettingsModal() : closeSettingsModal())}
/>
{/* Hidden file input for workspace import */} {/* Hidden file input for workspace import */}
<input <input

View File

@@ -1,3 +1,6 @@
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { import {
@@ -72,6 +75,11 @@ export class BlockExecutor {
try { try {
resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block) resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
if (block.metadata?.id === BlockType.AGENT && resolvedInputs.tools) {
resolvedInputs = await this.filterUnavailableMcpToolsForLog(ctx, resolvedInputs)
}
if (blockLog) { if (blockLog) {
blockLog.input = resolvedInputs blockLog.input = resolvedInputs
} }
@@ -395,6 +403,60 @@ export class BlockExecutor {
return undefined return undefined
} }
/**
* Filters out unavailable MCP tools from agent inputs for logging.
* Only includes tools from servers with 'connected' status.
*/
private async filterUnavailableMcpToolsForLog(
ctx: ExecutionContext,
inputs: Record<string, any>
): Promise<Record<string, any>> {
const tools = inputs.tools
if (!Array.isArray(tools) || tools.length === 0) return inputs
const mcpTools = tools.filter((t: any) => t.type === 'mcp')
if (mcpTools.length === 0) return inputs
const serverIds = [
...new Set(mcpTools.map((t: any) => t.params?.serverId).filter(Boolean)),
] as string[]
if (serverIds.length === 0) return inputs
const availableServerIds = new Set<string>()
if (ctx.workspaceId && serverIds.length > 0) {
try {
const servers = await db
.select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus })
.from(mcpServers)
.where(
and(
eq(mcpServers.workspaceId, ctx.workspaceId),
inArray(mcpServers.id, serverIds),
isNull(mcpServers.deletedAt)
)
)
for (const server of servers) {
if (server.connectionStatus === 'connected') {
availableServerIds.add(server.id)
}
}
} catch (error) {
logger.warn('Failed to check MCP server availability for logging:', error)
return inputs
}
}
const filteredTools = tools.filter((tool: any) => {
if (tool.type !== 'mcp') return true
const serverId = tool.params?.serverId
if (!serverId) return false
return availableServerIds.has(serverId)
})
return { ...inputs, tools: filteredTools }
}
private preparePauseResumeSelfReference( private preparePauseResumeSelfReference(
ctx: ExecutionContext, ctx: ExecutionContext,
node: DAGNode, node: DAGNode,

View File

@@ -1,3 +1,6 @@
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { createMcpToolId } from '@/lib/mcp/utils' import { createMcpToolId } from '@/lib/mcp/utils'
import { getAllBlocks } from '@/blocks' import { getAllBlocks } from '@/blocks'
@@ -35,19 +38,23 @@ export class AgentBlockHandler implements BlockHandler {
block: SerializedBlock, block: SerializedBlock,
inputs: AgentInputs inputs: AgentInputs
): Promise<BlockOutput | StreamingExecution> { ): Promise<BlockOutput | StreamingExecution> {
const responseFormat = this.parseResponseFormat(inputs.responseFormat) // Filter out unavailable MCP tools early so they don't appear in logs/inputs
const model = inputs.model || AGENT.DEFAULT_MODEL const filteredTools = await this.filterUnavailableMcpTools(ctx, inputs.tools || [])
const filteredInputs = { ...inputs, tools: filteredTools }
const responseFormat = this.parseResponseFormat(filteredInputs.responseFormat)
const model = filteredInputs.model || AGENT.DEFAULT_MODEL
const providerId = getProviderFromModel(model) const providerId = getProviderFromModel(model)
const formattedTools = await this.formatTools(ctx, inputs.tools || []) const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
const streamingConfig = this.getStreamingConfig(ctx, block) const streamingConfig = this.getStreamingConfig(ctx, block)
const messages = await this.buildMessages(ctx, inputs, block.id) const messages = await this.buildMessages(ctx, filteredInputs, block.id)
const providerRequest = this.buildProviderRequest({ const providerRequest = this.buildProviderRequest({
ctx, ctx,
providerId, providerId,
model, model,
messages, messages,
inputs, inputs: filteredInputs,
formattedTools, formattedTools,
responseFormat, responseFormat,
streaming: streamingConfig.shouldUseStreaming ?? false, streaming: streamingConfig.shouldUseStreaming ?? false,
@@ -58,10 +65,10 @@ export class AgentBlockHandler implements BlockHandler {
providerRequest, providerRequest,
block, block,
responseFormat, responseFormat,
inputs filteredInputs
) )
await this.persistResponseToMemory(ctx, inputs, result, block.id) await this.persistResponseToMemory(ctx, filteredInputs, result, block.id)
return result return result
} }
@@ -115,6 +122,53 @@ export class AgentBlockHandler implements BlockHandler {
return undefined return undefined
} }
private async filterUnavailableMcpTools(
ctx: ExecutionContext,
tools: ToolInput[]
): Promise<ToolInput[]> {
if (!Array.isArray(tools) || tools.length === 0) return tools
const mcpTools = tools.filter((t) => t.type === 'mcp')
if (mcpTools.length === 0) return tools
const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))]
if (serverIds.length === 0) return tools
const availableServerIds = new Set<string>()
if (ctx.workspaceId && serverIds.length > 0) {
try {
const servers = await db
.select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus })
.from(mcpServers)
.where(
and(
eq(mcpServers.workspaceId, ctx.workspaceId),
inArray(mcpServers.id, serverIds),
isNull(mcpServers.deletedAt)
)
)
for (const server of servers) {
if (server.connectionStatus === 'connected') {
availableServerIds.add(server.id)
}
}
} catch (error) {
logger.warn('Failed to check MCP server availability, including all tools:', error)
for (const serverId of serverIds) {
availableServerIds.add(serverId)
}
}
}
return tools.filter((tool) => {
if (tool.type !== 'mcp') return true
const serverId = tool.params?.serverId
if (!serverId) return false
return availableServerIds.has(serverId)
})
}
private async formatTools(ctx: ExecutionContext, inputTools: ToolInput[]): Promise<any[]> { private async formatTools(ctx: ExecutionContext, inputTools: ToolInput[]): Promise<any[]> {
if (!Array.isArray(inputTools)) return [] if (!Array.isArray(inputTools)) return []
@@ -304,6 +358,7 @@ export class AgentBlockHandler implements BlockHandler {
/** /**
* Process MCP tools using cached schemas from build time. * Process MCP tools using cached schemas from build time.
* Note: Unavailable tools are already filtered by filterUnavailableMcpTools.
*/ */
private async processMcpToolsBatched( private async processMcpToolsBatched(
ctx: ExecutionContext, ctx: ExecutionContext,
@@ -312,7 +367,6 @@ export class AgentBlockHandler implements BlockHandler {
if (mcpTools.length === 0) return [] if (mcpTools.length === 0) return []
const results: any[] = [] const results: any[] = []
const toolsWithSchema: ToolInput[] = [] const toolsWithSchema: ToolInput[] = []
const toolsNeedingDiscovery: ToolInput[] = [] const toolsNeedingDiscovery: ToolInput[] = []

View File

@@ -1,20 +1,17 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import type { McpServerStatusConfig } from '@/lib/mcp/types'
const logger = createLogger('McpQueries') const logger = createLogger('McpQueries')
/** export type { McpServerStatusConfig }
* Query key factories for MCP-related queries
*/
export const mcpKeys = { export const mcpKeys = {
all: ['mcp'] as const, all: ['mcp'] as const,
servers: (workspaceId: string) => [...mcpKeys.all, 'servers', workspaceId] as const, servers: (workspaceId: string) => [...mcpKeys.all, 'servers', workspaceId] as const,
tools: (workspaceId: string) => [...mcpKeys.all, 'tools', workspaceId] as const, tools: (workspaceId: string) => [...mcpKeys.all, 'tools', workspaceId] as const,
} }
/**
* MCP Server Types
*/
export interface McpServer { export interface McpServer {
id: string id: string
workspaceId: string workspaceId: string
@@ -25,9 +22,11 @@ export interface McpServer {
headers?: Record<string, string> headers?: Record<string, string>
enabled: boolean enabled: boolean
connectionStatus?: 'connected' | 'disconnected' | 'error' connectionStatus?: 'connected' | 'disconnected' | 'error'
lastError?: string lastError?: string | null
statusConfig?: McpServerStatusConfig
toolCount?: number toolCount?: number
lastToolsRefresh?: string lastToolsRefresh?: string
lastConnected?: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
deletedAt?: string deletedAt?: string
@@ -86,8 +85,13 @@ export function useMcpServers(workspaceId: string) {
/** /**
* Fetch MCP tools for a workspace * Fetch MCP tools for a workspace
*/ */
async function fetchMcpTools(workspaceId: string): Promise<McpTool[]> { async function fetchMcpTools(workspaceId: string, forceRefresh = false): Promise<McpTool[]> {
const response = await fetch(`/api/mcp/tools/discover?workspaceId=${workspaceId}`) const params = new URLSearchParams({ workspaceId })
if (forceRefresh) {
params.set('refresh', 'true')
}
const response = await fetch(`/api/mcp/tools/discover?${params.toString()}`)
// Treat 404 as "no tools available" - return empty array // Treat 404 as "no tools available" - return empty array
if (response.status === 404) { if (response.status === 404) {
@@ -159,14 +163,43 @@ export function useCreateMcpServer() {
return { return {
...serverData, ...serverData,
id: serverId, id: serverId,
connectionStatus: 'disconnected' as const, connectionStatus: 'connected' as const,
serverId, serverId,
updated: wasUpdated, updated: wasUpdated,
} }
}, },
onSuccess: (_data, variables) => { onSuccess: async (data, variables) => {
const freshTools = await fetchMcpTools(variables.workspaceId, true)
const previousServers = queryClient.getQueryData<McpServer[]>(
mcpKeys.servers(variables.workspaceId)
)
if (previousServers) {
const newServer: McpServer = {
id: data.id,
workspaceId: variables.workspaceId,
name: variables.config.name,
transport: variables.config.transport,
url: variables.config.url,
timeout: variables.config.timeout || 30000,
headers: variables.config.headers,
enabled: variables.config.enabled,
connectionStatus: 'connected',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
const serverExists = previousServers.some((s) => s.id === data.id)
queryClient.setQueryData<McpServer[]>(
mcpKeys.servers(variables.workspaceId),
serverExists
? previousServers.map((s) => (s.id === data.id ? { ...s, ...newServer } : s))
: [...previousServers, newServer]
)
}
queryClient.setQueryData(mcpKeys.tools(variables.workspaceId), freshTools)
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
}, },
}) })
} }
@@ -213,7 +246,7 @@ export function useDeleteMcpServer() {
interface UpdateMcpServerParams { interface UpdateMcpServerParams {
workspaceId: string workspaceId: string
serverId: string serverId: string
updates: Partial<McpServerConfig> updates: Partial<McpServerConfig & { enabled?: boolean }>
} }
export function useUpdateMcpServer() { export function useUpdateMcpServer() {
@@ -221,8 +254,20 @@ export function useUpdateMcpServer() {
return useMutation({ return useMutation({
mutationFn: async ({ workspaceId, serverId, updates }: UpdateMcpServerParams) => { mutationFn: async ({ workspaceId, serverId, updates }: UpdateMcpServerParams) => {
const response = await fetch(`/api/mcp/servers/${serverId}?workspaceId=${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update MCP server')
}
logger.info(`Updated MCP server: ${serverId} in workspace: ${workspaceId}`) logger.info(`Updated MCP server: ${serverId} in workspace: ${workspaceId}`)
return { serverId, updates } return data.data?.server
}, },
onMutate: async ({ workspaceId, serverId, updates }) => { onMutate: async ({ workspaceId, serverId, updates }) => {
await queryClient.cancelQueries({ queryKey: mcpKeys.servers(workspaceId) }) await queryClient.cancelQueries({ queryKey: mcpKeys.servers(workspaceId) })
@@ -249,6 +294,7 @@ export function useUpdateMcpServer() {
}, },
onSettled: (_data, _error, variables) => { onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
}, },
}) })
} }
@@ -292,9 +338,10 @@ export function useRefreshMcpServer() {
logger.info(`Refreshed MCP server: ${serverId}`) logger.info(`Refreshed MCP server: ${serverId}`)
return data.data return data.data
}, },
onSuccess: (_data, variables) => { onSuccess: async (_data, variables) => {
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) }) const freshTools = await fetchMcpTools(variables.workspaceId, true)
queryClient.setQueryData(mcpKeys.tools(variables.workspaceId), freshTools)
}, },
}) })
} }
@@ -349,3 +396,42 @@ export function useTestMcpServer() {
}, },
}) })
} }
/**
* Stored MCP tool from workflow state
*/
export interface StoredMcpTool {
workflowId: string
workflowName: string
serverId: string
serverUrl?: string
toolName: string
schema?: Record<string, unknown>
}
/**
* Fetch stored MCP tools from all workflows in the workspace
*/
async function fetchStoredMcpTools(workspaceId: string): Promise<StoredMcpTool[]> {
const response = await fetch(`/api/mcp/tools/stored?workspaceId=${workspaceId}`)
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to fetch stored MCP tools')
}
const data = await response.json()
return data.data?.tools || []
}
/**
* Hook to fetch stored MCP tools from all workflows
*/
export function useStoredMcpTools(workspaceId: string) {
return useQuery({
queryKey: [...mcpKeys.all, workspaceId, 'stored'],
queryFn: () => fetchStoredMcpTools(workspaceId),
enabled: !!workspaceId,
staleTime: 60 * 1000, // 1 minute - workflows don't change frequently
})
}

View File

@@ -34,14 +34,19 @@ export function useMcpServerTest() {
const [isTestingConnection, setIsTestingConnection] = useState(false) const [isTestingConnection, setIsTestingConnection] = useState(false)
const testConnection = useCallback( const testConnection = useCallback(
async (config: McpServerTestConfig): Promise<McpServerTestResult> => { async (
config: McpServerTestConfig,
options?: { silent?: boolean }
): Promise<McpServerTestResult> => {
const { silent = false } = options || {}
if (!config.name || !config.transport || !config.workspaceId) { if (!config.name || !config.transport || !config.workspaceId) {
const result: McpServerTestResult = { const result: McpServerTestResult = {
success: false, success: false,
message: 'Missing required configuration', message: 'Missing required configuration',
error: 'Please provide server name, transport method, and workspace ID', error: 'Please provide server name, transport method, and workspace ID',
} }
setTestResult(result) if (!silent) setTestResult(result)
return result return result
} }
@@ -51,12 +56,14 @@ export function useMcpServerTest() {
message: 'Missing server URL', message: 'Missing server URL',
error: 'Please provide a server URL for HTTP/SSE transport', error: 'Please provide a server URL for HTTP/SSE transport',
} }
setTestResult(result) if (!silent) setTestResult(result)
return result return result
} }
setIsTestingConnection(true) if (!silent) {
setTestResult(null) setIsTestingConnection(true)
setTestResult(null)
}
try { try {
const cleanConfig = { const cleanConfig = {
@@ -88,14 +95,14 @@ export function useMcpServerTest() {
error: result.data.error, error: result.data.error,
warnings: result.data.warnings, warnings: result.data.warnings,
} }
setTestResult(testResult) if (!silent) setTestResult(testResult)
logger.error('MCP server test failed:', result.data.error) logger.error('MCP server test failed:', result.data.error)
return testResult return testResult
} }
throw new Error(result.error || 'Connection test failed') throw new Error(result.error || 'Connection test failed')
} }
setTestResult(result.data || result) if (!silent) setTestResult(result.data || result)
logger.info(`MCP server test ${result.data?.success ? 'passed' : 'failed'}:`, config.name) logger.info(`MCP server test ${result.data?.success ? 'passed' : 'failed'}:`, config.name)
return result.data || result return result.data || result
} catch (error) { } catch (error) {
@@ -105,11 +112,11 @@ export function useMcpServerTest() {
message: 'Connection failed', message: 'Connection failed',
error: errorMessage, error: errorMessage,
} }
setTestResult(result) if (!silent) setTestResult(result)
logger.error('MCP server test failed:', errorMessage) logger.error('MCP server test failed:', errorMessage)
return result return result
} finally { } finally {
setIsTestingConnection(false) if (!silent) setIsTestingConnection(false)
} }
}, },
[] []

View File

@@ -108,7 +108,7 @@ export class McpClient {
this.connectionStatus.lastError = errorMessage this.connectionStatus.lastError = errorMessage
this.isConnected = false this.isConnected = false
logger.error(`Failed to connect to MCP server ${this.config.name}:`, error) logger.error(`Failed to connect to MCP server ${this.config.name}:`, error)
throw new McpConnectionError(errorMessage, this.config.id) throw new McpConnectionError(errorMessage, this.config.name)
} }
} }
@@ -141,7 +141,7 @@ export class McpClient {
*/ */
async listTools(): Promise<McpTool[]> { async listTools(): Promise<McpTool[]> {
if (!this.isConnected) { if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.id) throw new McpConnectionError('Not connected to server', this.config.name)
} }
try { try {
@@ -170,7 +170,7 @@ export class McpClient {
*/ */
async callTool(toolCall: McpToolCall): Promise<McpToolResult> { async callTool(toolCall: McpToolCall): Promise<McpToolResult> {
if (!this.isConnected) { if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.id) throw new McpConnectionError('Not connected to server', this.config.name)
} }
const consentRequest: McpConsentRequest = { const consentRequest: McpConsentRequest = {
@@ -217,7 +217,7 @@ export class McpClient {
*/ */
async ping(): Promise<{ _meta?: Record<string, any> }> { async ping(): Promise<{ _meta?: Record<string, any> }> {
if (!this.isConnected) { if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.id) throw new McpConnectionError('Not connected to server', this.config.name)
} }
try { try {

View File

@@ -10,8 +10,14 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { McpClient } from '@/lib/mcp/client' import { McpClient } from '@/lib/mcp/client'
import {
createMcpCacheAdapter,
getMcpCacheType,
type McpCacheStorageAdapter,
} from '@/lib/mcp/storage'
import type { import type {
McpServerConfig, McpServerConfig,
McpServerStatusConfig,
McpServerSummary, McpServerSummary,
McpTool, McpTool,
McpToolCall, McpToolCall,
@@ -22,154 +28,21 @@ import { MCP_CONSTANTS } from '@/lib/mcp/utils'
const logger = createLogger('McpService') const logger = createLogger('McpService')
interface ToolCache {
tools: McpTool[]
expiry: Date
lastAccessed: Date
}
interface CacheStats {
totalEntries: number
activeEntries: number
expiredEntries: number
maxCacheSize: number
cacheHitRate: number
memoryUsage: {
approximateBytes: number
entriesEvicted: number
}
}
class McpService { class McpService {
private toolCache = new Map<string, ToolCache>() private cacheAdapter: McpCacheStorageAdapter
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT // 30 seconds private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT // 5 minutes
private readonly maxCacheSize = MCP_CONSTANTS.MAX_CACHE_SIZE // 1000
private cleanupInterval: NodeJS.Timeout | null = null
private cacheHits = 0
private cacheMisses = 0
private entriesEvicted = 0
constructor() { constructor() {
this.startPeriodicCleanup() this.cacheAdapter = createMcpCacheAdapter()
} logger.info(`MCP Service initialized with ${getMcpCacheType()} cache`)
/**
* Start periodic cleanup of expired cache entries
*/
private startPeriodicCleanup(): void {
this.cleanupInterval = setInterval(
() => {
this.cleanupExpiredEntries()
},
5 * 60 * 1000
)
}
/**
* Stop periodic cleanup
*/
private stopPeriodicCleanup(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
/**
* Cleanup expired cache entries
*/
private cleanupExpiredEntries(): void {
const now = new Date()
const expiredKeys: string[] = []
this.toolCache.forEach((cache, key) => {
if (cache.expiry <= now) {
expiredKeys.push(key)
}
})
expiredKeys.forEach((key) => this.toolCache.delete(key))
if (expiredKeys.length > 0) {
logger.debug(`Cleaned up ${expiredKeys.length} expired cache entries`)
}
}
/**
* Evict least recently used entries when cache exceeds max size
*/
private evictLRUEntries(): void {
if (this.toolCache.size <= this.maxCacheSize) {
return
}
const entries: { key: string; cache: ToolCache }[] = []
this.toolCache.forEach((cache, key) => {
entries.push({ key, cache })
})
entries.sort((a, b) => a.cache.lastAccessed.getTime() - b.cache.lastAccessed.getTime())
const entriesToRemove = this.toolCache.size - this.maxCacheSize + 1
for (let i = 0; i < entriesToRemove && i < entries.length; i++) {
this.toolCache.delete(entries[i].key)
this.entriesEvicted++
}
logger.debug(`Evicted ${entriesToRemove} LRU cache entries to maintain size limit`)
}
/**
* Get cache entry and update last accessed time
*/
private getCacheEntry(key: string): ToolCache | undefined {
const entry = this.toolCache.get(key)
if (entry) {
entry.lastAccessed = new Date()
this.cacheHits++
return entry
}
this.cacheMisses++
return undefined
}
/**
* Set cache entry with LRU eviction
*/
private setCacheEntry(key: string, tools: McpTool[]): void {
const now = new Date()
const cache: ToolCache = {
tools,
expiry: new Date(now.getTime() + this.cacheTimeout),
lastAccessed: now,
}
this.toolCache.set(key, cache)
this.evictLRUEntries()
}
/**
* Calculate approximate memory usage of cache
*/
private calculateMemoryUsage(): number {
let totalBytes = 0
this.toolCache.forEach((cache, key) => {
totalBytes += key.length * 2 // UTF-16 encoding
totalBytes += JSON.stringify(cache.tools).length * 2
totalBytes += 64
})
return totalBytes
} }
/** /**
* Dispose of the service and cleanup resources * Dispose of the service and cleanup resources
*/ */
dispose(): void { dispose(): void {
this.stopPeriodicCleanup() this.cacheAdapter.dispose()
this.toolCache.clear() logger.info('MCP Service disposed')
logger.info('MCP Service disposed and cleanup stopped')
} }
/** /**
@@ -385,6 +258,81 @@ class McpService {
) )
} }
/**
* Update server connection status after discovery attempt
*/
private async updateServerStatus(
serverId: string,
workspaceId: string,
success: boolean,
error?: string,
toolCount?: number
): Promise<void> {
try {
const [currentServer] = await db
.select({ statusConfig: mcpServers.statusConfig })
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
const currentConfig: McpServerStatusConfig =
(currentServer?.statusConfig as McpServerStatusConfig | null) ?? {
consecutiveFailures: 0,
lastSuccessfulDiscovery: null,
}
const now = new Date()
if (success) {
await db
.update(mcpServers)
.set({
connectionStatus: 'connected',
lastConnected: now,
lastError: null,
toolCount: toolCount ?? 0,
lastToolsRefresh: now,
statusConfig: {
consecutiveFailures: 0,
lastSuccessfulDiscovery: now.toISOString(),
},
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
} else {
const newFailures = currentConfig.consecutiveFailures + 1
const isErrorState = newFailures >= MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES
await db
.update(mcpServers)
.set({
connectionStatus: isErrorState ? 'error' : 'disconnected',
lastError: error || 'Unknown error',
statusConfig: {
consecutiveFailures: newFailures,
lastSuccessfulDiscovery: currentConfig.lastSuccessfulDiscovery,
},
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
if (isErrorState) {
logger.warn(
`Server ${serverId} marked as error after ${newFailures} consecutive failures`
)
}
}
} catch (err) {
logger.error(`Failed to update server status for ${serverId}:`, err)
}
}
/** /**
* Discover tools from all workspace servers * Discover tools from all workspace servers
*/ */
@@ -399,10 +347,14 @@ class McpService {
try { try {
if (!forceRefresh) { if (!forceRefresh) {
const cached = this.getCacheEntry(cacheKey) try {
if (cached && cached.expiry > new Date()) { const cached = await this.cacheAdapter.get(cacheKey)
logger.debug(`[${requestId}] Using cached tools for user ${userId}`) if (cached) {
return cached.tools logger.debug(`[${requestId}] Using cached tools for user ${userId}`)
return cached.tools
}
} catch (error) {
logger.warn(`[${requestId}] Cache read failed, proceeding with discovery:`, error)
} }
} }
@@ -425,7 +377,7 @@ class McpService {
logger.debug( logger.debug(
`[${requestId}] Discovered ${tools.length} tools from server ${config.name}` `[${requestId}] Discovered ${tools.length} tools from server ${config.name}`
) )
return tools return { serverId: config.id, tools }
} finally { } finally {
await client.disconnect() await client.disconnect()
} }
@@ -433,20 +385,44 @@ class McpService {
) )
let failedCount = 0 let failedCount = 0
const statusUpdates: Promise<void>[] = []
results.forEach((result, index) => { results.forEach((result, index) => {
const server = servers[index]
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
allTools.push(...result.value) allTools.push(...result.value.tools)
statusUpdates.push(
this.updateServerStatus(
server.id!,
workspaceId,
true,
undefined,
result.value.tools.length
)
)
} else { } else {
failedCount++ failedCount++
const errorMessage =
result.reason instanceof Error ? result.reason.message : 'Unknown error'
logger.warn( logger.warn(
`[${requestId}] Failed to discover tools from server ${servers[index].name}:`, `[${requestId}] Failed to discover tools from server ${server.name}:`,
result.reason result.reason
) )
statusUpdates.push(this.updateServerStatus(server.id!, workspaceId, false, errorMessage))
} }
}) })
// Update server statuses in parallel (don't block on this)
Promise.allSettled(statusUpdates).catch((err) => {
logger.error(`[${requestId}] Error updating server statuses:`, err)
})
if (failedCount === 0) { if (failedCount === 0) {
this.setCacheEntry(cacheKey, allTools) try {
await this.cacheAdapter.set(cacheKey, allTools, this.cacheTimeout)
} catch (error) {
logger.warn(`[${requestId}] Cache write failed:`, error)
}
} else { } else {
logger.warn( logger.warn(
`[${requestId}] Skipping cache due to ${failedCount} failed server(s) - will retry on next request` `[${requestId}] Skipping cache due to ${failedCount} failed server(s) - will retry on next request`
@@ -565,44 +541,18 @@ class McpService {
/** /**
* Clear tool cache for a workspace or all workspaces * Clear tool cache for a workspace or all workspaces
*/ */
clearCache(workspaceId?: string): void { async clearCache(workspaceId?: string): Promise<void> {
if (workspaceId) { try {
const workspaceCacheKey = `workspace:${workspaceId}` if (workspaceId) {
this.toolCache.delete(workspaceCacheKey) const workspaceCacheKey = `workspace:${workspaceId}`
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId}`) await this.cacheAdapter.delete(workspaceCacheKey)
} else { logger.debug(`Cleared MCP tool cache for workspace ${workspaceId}`)
this.toolCache.clear() } else {
this.cacheHits = 0 await this.cacheAdapter.clear()
this.cacheMisses = 0 logger.debug('Cleared all MCP tool cache')
this.entriesEvicted = 0 }
logger.debug('Cleared all MCP tool cache and reset statistics') } catch (error) {
} logger.warn('Failed to clear cache:', error)
}
/**
* Get comprehensive cache statistics
*/
getCacheStats(): CacheStats {
const entries: { key: string; cache: ToolCache }[] = []
this.toolCache.forEach((cache, key) => {
entries.push({ key, cache })
})
const now = new Date()
const activeEntries = entries.filter(({ cache }) => cache.expiry > now)
const totalRequests = this.cacheHits + this.cacheMisses
const hitRate = totalRequests > 0 ? this.cacheHits / totalRequests : 0
return {
totalEntries: entries.length,
activeEntries: activeEntries.length,
expiredEntries: entries.length - activeEntries.length,
maxCacheSize: this.maxCacheSize,
cacheHitRate: Math.round(hitRate * 100) / 100,
memoryUsage: {
approximateBytes: this.calculateMemoryUsage(),
entriesEvicted: this.entriesEvicted,
},
} }
} }
} }

View File

@@ -0,0 +1,14 @@
import type { McpTool } from '@/lib/mcp/types'
export interface McpCacheEntry {
tools: McpTool[]
expiry: number // Unix timestamp ms
}
export interface McpCacheStorageAdapter {
get(key: string): Promise<McpCacheEntry | null>
set(key: string, tools: McpTool[], ttlMs: number): Promise<void>
delete(key: string): Promise<void>
clear(): Promise<void>
dispose(): void
}

View File

@@ -0,0 +1,53 @@
import { getRedisClient } from '@/lib/core/config/redis'
import { createLogger } from '@/lib/logs/console/logger'
import type { McpCacheStorageAdapter } from './adapter'
import { MemoryMcpCache } from './memory-cache'
import { RedisMcpCache } from './redis-cache'
const logger = createLogger('McpCacheFactory')
let cachedAdapter: McpCacheStorageAdapter | null = null
/**
* Create MCP cache storage adapter.
* Uses Redis if available, falls back to in-memory cache.
*
* Unlike rate-limiting (which fails if Redis is configured but unavailable),
* MCP caching gracefully falls back to memory since it's an optimization.
*/
export function createMcpCacheAdapter(): McpCacheStorageAdapter {
if (cachedAdapter) {
return cachedAdapter
}
const redis = getRedisClient()
if (redis) {
logger.info('MCP cache: Using Redis')
cachedAdapter = new RedisMcpCache(redis)
} else {
logger.info('MCP cache: Using in-memory (Redis not configured)')
cachedAdapter = new MemoryMcpCache()
}
return cachedAdapter
}
/**
* Get the current adapter type for logging/debugging
*/
export function getMcpCacheType(): 'redis' | 'memory' {
const redis = getRedisClient()
return redis ? 'redis' : 'memory'
}
/**
* Reset the cached adapter.
* Only use for testing purposes.
*/
export function resetMcpCacheAdapter(): void {
if (cachedAdapter) {
cachedAdapter.dispose()
cachedAdapter = null
}
}

View File

@@ -0,0 +1,4 @@
export type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
export { createMcpCacheAdapter, getMcpCacheType, resetMcpCacheAdapter } from './factory'
export { MemoryMcpCache } from './memory-cache'
export { RedisMcpCache } from './redis-cache'

View File

@@ -0,0 +1,103 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { McpTool } from '@/lib/mcp/types'
import { MCP_CONSTANTS } from '@/lib/mcp/utils'
import type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
const logger = createLogger('McpMemoryCache')
export class MemoryMcpCache implements McpCacheStorageAdapter {
private cache = new Map<string, McpCacheEntry>()
private readonly maxCacheSize = MCP_CONSTANTS.MAX_CACHE_SIZE
private cleanupInterval: NodeJS.Timeout | null = null
constructor() {
this.startPeriodicCleanup()
}
private startPeriodicCleanup(): void {
this.cleanupInterval = setInterval(
() => {
this.cleanupExpiredEntries()
},
5 * 60 * 1000 // 5 minutes
)
// Don't keep Node process alive just for cache cleanup
this.cleanupInterval.unref()
}
private cleanupExpiredEntries(): void {
const now = Date.now()
const expiredKeys: string[] = []
this.cache.forEach((entry, key) => {
if (entry.expiry <= now) {
expiredKeys.push(key)
}
})
expiredKeys.forEach((key) => this.cache.delete(key))
if (expiredKeys.length > 0) {
logger.debug(`Cleaned up ${expiredKeys.length} expired cache entries`)
}
}
private evictIfNeeded(): void {
if (this.cache.size <= this.maxCacheSize) {
return
}
// Evict oldest entries (by insertion order - Map maintains order)
const entriesToRemove = this.cache.size - this.maxCacheSize
const keys = Array.from(this.cache.keys()).slice(0, entriesToRemove)
keys.forEach((key) => this.cache.delete(key))
logger.debug(`Evicted ${entriesToRemove} cache entries`)
}
async get(key: string): Promise<McpCacheEntry | null> {
const entry = this.cache.get(key)
const now = Date.now()
if (!entry || entry.expiry <= now) {
if (entry) {
this.cache.delete(key)
}
return null
}
// Return copy to prevent caller from mutating cache
return {
tools: entry.tools,
expiry: entry.expiry,
}
}
async set(key: string, tools: McpTool[], ttlMs: number): Promise<void> {
const now = Date.now()
const entry: McpCacheEntry = {
tools,
expiry: now + ttlMs,
}
this.cache.set(key, entry)
this.evictIfNeeded()
}
async delete(key: string): Promise<void> {
this.cache.delete(key)
}
async clear(): Promise<void> {
this.cache.clear()
}
dispose(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
this.cache.clear()
logger.info('Memory cache disposed')
}
}

View File

@@ -0,0 +1,96 @@
import type Redis from 'ioredis'
import { createLogger } from '@/lib/logs/console/logger'
import type { McpTool } from '@/lib/mcp/types'
import type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
const logger = createLogger('McpRedisCache')
const REDIS_KEY_PREFIX = 'mcp:tools:'
export class RedisMcpCache implements McpCacheStorageAdapter {
constructor(private redis: Redis) {}
private getKey(key: string): string {
return `${REDIS_KEY_PREFIX}${key}`
}
async get(key: string): Promise<McpCacheEntry | null> {
try {
const redisKey = this.getKey(key)
const data = await this.redis.get(redisKey)
if (!data) {
return null
}
try {
return JSON.parse(data) as McpCacheEntry
} catch {
// Corrupted data - delete and treat as miss
logger.warn('Corrupted cache entry, deleting:', redisKey)
await this.redis.del(redisKey)
return null
}
} catch (error) {
logger.error('Redis cache get error:', error)
throw error
}
}
async set(key: string, tools: McpTool[], ttlMs: number): Promise<void> {
try {
const now = Date.now()
const entry: McpCacheEntry = {
tools,
expiry: now + ttlMs,
}
await this.redis.set(this.getKey(key), JSON.stringify(entry), 'PX', ttlMs)
} catch (error) {
logger.error('Redis cache set error:', error)
throw error
}
}
async delete(key: string): Promise<void> {
try {
await this.redis.del(this.getKey(key))
} catch (error) {
logger.error('Redis cache delete error:', error)
throw error
}
}
async clear(): Promise<void> {
try {
let cursor = '0'
let deletedCount = 0
do {
const [nextCursor, keys] = await this.redis.scan(
cursor,
'MATCH',
`${REDIS_KEY_PREFIX}*`,
'COUNT',
100
)
cursor = nextCursor
if (keys.length > 0) {
await this.redis.del(...keys)
deletedCount += keys.length
}
} while (cursor !== '0')
logger.debug(`Cleared ${deletedCount} MCP cache entries from Redis`)
} catch (error) {
logger.error('Redis cache clear error:', error)
throw error
}
}
dispose(): void {
// Redis client is managed externally, nothing to dispose
logger.info('Redis cache adapter disposed')
}
}

View File

@@ -0,0 +1,129 @@
/**
* MCP Tool Validation
*
* Shared logic for detecting issues with MCP tools across the platform.
* Used by both tool-input.tsx (workflow context) and MCP modal (workspace context).
*/
import isEqual from 'lodash/isEqual'
import omit from 'lodash/omit'
export type McpToolIssueType =
| 'server_not_found'
| 'server_error'
| 'tool_not_found'
| 'schema_changed'
| 'url_changed'
export interface McpToolIssue {
type: McpToolIssueType
message: string
}
export interface StoredMcpTool {
serverId: string
serverUrl?: string
toolName: string
schema?: Record<string, unknown>
}
export interface ServerState {
id: string
url?: string
connectionStatus?: 'connected' | 'disconnected' | 'error'
lastError?: string
}
export interface DiscoveredTool {
serverId: string
name: string
inputSchema?: Record<string, unknown>
}
/**
* Compares two schemas to detect changes.
* Uses lodash isEqual for deep, key-order-independent comparison.
* Ignores description field which may be backfilled.
*/
export function hasSchemaChanged(
storedSchema: Record<string, unknown> | undefined,
serverSchema: Record<string, unknown> | undefined
): boolean {
if (!storedSchema || !serverSchema) return false
const storedWithoutDesc = omit(storedSchema, 'description')
const serverWithoutDesc = omit(serverSchema, 'description')
return !isEqual(storedWithoutDesc, serverWithoutDesc)
}
/**
* Detects issues with a stored MCP tool by comparing against current server/tool state.
*/
export function getMcpToolIssue(
storedTool: StoredMcpTool,
servers: ServerState[],
discoveredTools: DiscoveredTool[]
): McpToolIssue | null {
const { serverId, serverUrl, toolName, schema } = storedTool
// Check server exists
const server = servers.find((s) => s.id === serverId)
if (!server) {
return { type: 'server_not_found', message: 'Server not found' }
}
// Check server connection status
if (server.connectionStatus === 'error') {
return { type: 'server_error', message: server.lastError || 'Server connection error' }
}
if (server.connectionStatus !== 'connected') {
return { type: 'server_error', message: 'Server not connected' }
}
// Check server URL changed (if we have stored URL)
if (serverUrl && server.url && serverUrl !== server.url) {
return { type: 'url_changed', message: 'Server URL changed - tools may be different' }
}
// Check tool exists on server
const serverTool = discoveredTools.find((t) => t.serverId === serverId && t.name === toolName)
if (!serverTool) {
return { type: 'tool_not_found', message: 'Tool not found on server' }
}
// Check schema changed
if (schema && serverTool.inputSchema) {
if (hasSchemaChanged(schema, serverTool.inputSchema)) {
return { type: 'schema_changed', message: 'Tool schema changed' }
}
}
return null
}
/**
* Returns a user-friendly label for the issue badge
*/
export function getIssueBadgeLabel(issue: McpToolIssue): string {
switch (issue.type) {
case 'schema_changed':
return 'stale'
case 'url_changed':
return 'stale'
default:
return 'unavailable'
}
}
/**
* Checks if an issue means the tool cannot be used (vs just being stale)
*/
export function isToolUnavailable(issue: McpToolIssue | null): boolean {
if (!issue) return false
return (
issue.type === 'server_not_found' ||
issue.type === 'server_error' ||
issue.type === 'tool_not_found'
)
}

View File

@@ -6,6 +6,11 @@
// Modern MCP uses Streamable HTTP which handles both HTTP POST and SSE responses // Modern MCP uses Streamable HTTP which handles both HTTP POST and SSE responses
export type McpTransport = 'streamable-http' export type McpTransport = 'streamable-http'
export interface McpServerStatusConfig {
consecutiveFailures: number
lastSuccessfulDiscovery: string | null
}
export interface McpServerConfig { export interface McpServerConfig {
id: string id: string
name: string name: string
@@ -20,6 +25,7 @@ export interface McpServerConfig {
timeout?: number timeout?: number
retries?: number retries?: number
enabled?: boolean enabled?: boolean
statusConfig?: McpServerStatusConfig
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
} }
@@ -113,8 +119,8 @@ export class McpError extends Error {
} }
export class McpConnectionError extends McpError { export class McpConnectionError extends McpError {
constructor(message: string, serverId: string) { constructor(message: string, serverName: string) {
super(`MCP Connection Error for server ${serverId}: ${message}`) super(`Failed to connect to "${serverName}": ${message}`)
this.name = 'McpConnectionError' this.name = 'McpConnectionError'
} }
} }

View File

@@ -6,10 +6,11 @@ import type { McpApiResponse } from '@/lib/mcp/types'
*/ */
export const MCP_CONSTANTS = { export const MCP_CONSTANTS = {
EXECUTION_TIMEOUT: 60000, EXECUTION_TIMEOUT: 60000,
CACHE_TIMEOUT: 30 * 1000, CACHE_TIMEOUT: 5 * 60 * 1000, // 5 minutes
DEFAULT_RETRIES: 3, DEFAULT_RETRIES: 3,
DEFAULT_CONNECTION_TIMEOUT: 30000, DEFAULT_CONNECTION_TIMEOUT: 30000,
MAX_CACHE_SIZE: 1000, MAX_CACHE_SIZE: 1000,
MAX_CONSECUTIVE_FAILURES: 3,
} as const } as const
/** /**

View File

@@ -1001,7 +1001,7 @@ export function supportsToolUsageControl(provider: string): boolean {
* Prepare tool execution parameters, separating tool parameters from system parameters * Prepare tool execution parameters, separating tool parameters from system parameters
*/ */
export function prepareToolExecution( export function prepareToolExecution(
tool: { params?: Record<string, any> }, tool: { params?: Record<string, any>; parameters?: Record<string, any> },
llmArgs: Record<string, any>, llmArgs: Record<string, any>,
request: { request: {
workflowId?: string workflowId?: string
@@ -1051,6 +1051,8 @@ export function prepareToolExecution(
...(request.workflowVariables ? { workflowVariables: request.workflowVariables } : {}), ...(request.workflowVariables ? { workflowVariables: request.workflowVariables } : {}),
...(request.blockData ? { blockData: request.blockData } : {}), ...(request.blockData ? { blockData: request.blockData } : {}),
...(request.blockNameMapping ? { blockNameMapping: request.blockNameMapping } : {}), ...(request.blockNameMapping ? { blockNameMapping: request.blockNameMapping } : {}),
// Pass tool schema for MCP tools to skip discovery
...(tool.parameters ? { _toolSchema: tool.parameters } : {}),
} }
return { toolParams, executionParams } return { toolParams, executionParams }

View File

@@ -0,0 +1,51 @@
'use client'
import { create } from 'zustand'
type SettingsSection =
| 'general'
| 'environment'
| 'template-profile'
| 'integrations'
| 'apikeys'
| 'files'
| 'subscription'
| 'team'
| 'sso'
| 'copilot'
| 'mcp'
| 'custom-tools'
interface SettingsModalState {
isOpen: boolean
initialSection: SettingsSection | null
mcpServerId: string | null
openModal: (options?: { section?: SettingsSection; mcpServerId?: string }) => void
closeModal: () => void
clearInitialState: () => void
}
export const useSettingsModalStore = create<SettingsModalState>((set) => ({
isOpen: false,
initialSection: null,
mcpServerId: null,
openModal: (options) =>
set({
isOpen: true,
initialSection: options?.section || null,
mcpServerId: options?.mcpServerId || null,
}),
closeModal: () =>
set({
isOpen: false,
}),
clearInitialState: () =>
set({
initialSection: null,
mcpServerId: null,
}),
}))

View File

@@ -107,6 +107,7 @@ const MCP_SYSTEM_PARAMETERS = new Set([
'workflowVariables', 'workflowVariables',
'blockData', 'blockData',
'blockNameMapping', 'blockNameMapping',
'_toolSchema',
]) ])
/** /**
@@ -979,7 +980,10 @@ async function executeMcpTool(
} }
} }
const requestBody = { // Get tool schema if provided (from agent block's cached schema)
const toolSchema = params._toolSchema
const requestBody: Record<string, any> = {
serverId, serverId,
toolName, toolName,
arguments: toolArguments, arguments: toolArguments,
@@ -987,6 +991,11 @@ async function executeMcpTool(
workspaceId, // Pass workspace context for scoping workspaceId, // Pass workspace context for scoping
} }
// Include schema to skip discovery on execution
if (toolSchema) {
requestBody.toolSchema = toolSchema
}
const body = JSON.stringify(requestBody) const body = JSON.stringify(requestBody)
// Check request body size before sending // Check request body size before sending
@@ -995,6 +1004,7 @@ async function executeMcpTool(
logger.info(`[${actualRequestId}] Making MCP tool request to ${toolName} on ${serverId}`, { logger.info(`[${actualRequestId}] Making MCP tool request to ${toolName} on ${serverId}`, {
hasWorkspaceId: !!workspaceId, hasWorkspaceId: !!workspaceId,
hasWorkflowId: !!workflowId, hasWorkflowId: !!workflowId,
hasToolSchema: !!toolSchema,
}) })
const response = await fetch(`${baseUrl}/api/mcp/tools/execute`, { const response = await fetch(`${baseUrl}/api/mcp/tools/execute`, {

View File

@@ -0,0 +1 @@
ALTER TABLE "mcp_servers" ADD COLUMN "status_config" jsonb DEFAULT '{}';

File diff suppressed because it is too large Load Diff

View File

@@ -855,6 +855,13 @@
"when": 1765587157593, "when": 1765587157593,
"tag": "0122_pale_absorbing_man", "tag": "0122_pale_absorbing_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 123,
"version": "7",
"when": 1765932898404,
"tag": "0123_windy_lockheed",
"breakpoints": true
} }
] ]
} }

View File

@@ -1547,6 +1547,8 @@ export const mcpServers = pgTable(
connectionStatus: text('connection_status').default('disconnected'), connectionStatus: text('connection_status').default('disconnected'),
lastError: text('last_error'), lastError: text('last_error'),
statusConfig: jsonb('status_config').default('{}'),
toolCount: integer('tool_count').default(0), toolCount: integer('tool_count').default(0),
lastToolsRefresh: timestamp('last_tools_refresh'), lastToolsRefresh: timestamp('last_tools_refresh'),
totalRequests: integer('total_requests').default(0), totalRequests: integer('total_requests').default(0),