mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
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:
committed by
GitHub
parent
b7228d57f7
commit
de330d80f5
@@ -5,6 +5,7 @@ import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import type { McpServerStatusConfig } from '@/lib/mcp/types'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('McpServerRefreshAPI')
|
||||
@@ -50,6 +51,12 @@ export const POST = withMcpAuth<{ id: string }>('read')(
|
||||
let toolCount = 0
|
||||
let lastError: string | null = null
|
||||
|
||||
const currentStatusConfig: McpServerStatusConfig =
|
||||
(server.statusConfig as McpServerStatusConfig | null) ?? {
|
||||
consecutiveFailures: 0,
|
||||
lastSuccessfulDiscovery: null,
|
||||
}
|
||||
|
||||
try {
|
||||
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
|
||||
connectionStatus = 'connected'
|
||||
@@ -63,20 +70,40 @@ export const POST = withMcpAuth<{ id: string }>('read')(
|
||||
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
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
lastToolsRefresh: new Date(),
|
||||
lastToolsRefresh: now,
|
||||
connectionStatus,
|
||||
lastError,
|
||||
lastConnected: connectionStatus === 'connected' ? new Date() : server.lastConnected,
|
||||
lastConnected: connectionStatus === 'connected' ? now : server.lastConnected,
|
||||
toolCount,
|
||||
updatedAt: new Date(),
|
||||
statusConfig: newStatusConfig,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(mcpServers.id, serverId))
|
||||
.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({
|
||||
status: connectionStatus,
|
||||
toolCount,
|
||||
|
||||
@@ -48,6 +48,19 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
|
||||
// Remove workspaceId from body to prevent it from being updated
|
||||
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
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
@@ -71,8 +84,12 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
|
||||
)
|
||||
}
|
||||
|
||||
// Clear MCP service cache after update
|
||||
mcpService.clearCache(workspaceId)
|
||||
// Only clear cache if URL changed (requires re-discovery)
|
||||
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}`)
|
||||
return createMcpSuccessResponse({ server: updatedServer })
|
||||
|
||||
@@ -117,12 +117,14 @@ export const POST = withMcpAuth('write')(
|
||||
timeout: body.timeout || 30000,
|
||||
retries: body.retries || 3,
|
||||
enabled: body.enabled !== false,
|
||||
connectionStatus: 'connected',
|
||||
lastConnected: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
})
|
||||
.where(eq(mcpServers.id, serverId))
|
||||
|
||||
mcpService.clearCache(workspaceId)
|
||||
await mcpService.clearCache(workspaceId)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})`
|
||||
@@ -145,12 +147,14 @@ export const POST = withMcpAuth('write')(
|
||||
timeout: body.timeout || 30000,
|
||||
retries: body.retries || 3,
|
||||
enabled: body.enabled !== false,
|
||||
connectionStatus: 'connected',
|
||||
lastConnected: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
mcpService.clearCache(workspaceId)
|
||||
await mcpService.clearCache(workspaceId)
|
||||
|
||||
logger.info(
|
||||
`[${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}`)
|
||||
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
+47
-35
@@ -18,12 +18,18 @@ interface McpTool {
|
||||
inputSchema?: any
|
||||
}
|
||||
|
||||
interface McpServer {
|
||||
id: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface StoredTool {
|
||||
type: 'mcp'
|
||||
title: string
|
||||
toolId: string
|
||||
params: {
|
||||
serverId: string
|
||||
serverUrl?: string
|
||||
toolName: string
|
||||
serverName: string
|
||||
}
|
||||
@@ -34,6 +40,7 @@ interface StoredTool {
|
||||
|
||||
interface McpToolsListProps {
|
||||
mcpTools: McpTool[]
|
||||
mcpServers?: McpServer[]
|
||||
searchQuery: string
|
||||
customFilter: (name: string, query: string) => number
|
||||
onToolSelect: (tool: StoredTool) => void
|
||||
@@ -45,6 +52,7 @@ interface McpToolsListProps {
|
||||
*/
|
||||
export function McpToolsList({
|
||||
mcpTools,
|
||||
mcpServers = [],
|
||||
searchQuery,
|
||||
customFilter,
|
||||
onToolSelect,
|
||||
@@ -59,44 +67,48 @@ export function McpToolsList({
|
||||
return (
|
||||
<>
|
||||
<PopoverSection>MCP Tools</PopoverSection>
|
||||
{filteredTools.map((mcpTool) => (
|
||||
<ToolCommand.Item
|
||||
key={mcpTool.id}
|
||||
value={mcpTool.name}
|
||||
onSelect={() => {
|
||||
if (disabled) return
|
||||
{filteredTools.map((mcpTool) => {
|
||||
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
|
||||
return (
|
||||
<ToolCommand.Item
|
||||
key={mcpTool.id}
|
||||
value={mcpTool.name}
|
||||
onSelect={() => {
|
||||
if (disabled) return
|
||||
|
||||
const newTool: StoredTool = {
|
||||
type: 'mcp',
|
||||
title: mcpTool.name,
|
||||
toolId: mcpTool.id,
|
||||
params: {
|
||||
serverId: mcpTool.serverId,
|
||||
toolName: mcpTool.name,
|
||||
serverName: mcpTool.serverName,
|
||||
},
|
||||
isExpanded: true,
|
||||
usageControl: 'auto',
|
||||
schema: {
|
||||
...mcpTool.inputSchema,
|
||||
description: mcpTool.description,
|
||||
},
|
||||
}
|
||||
const newTool: StoredTool = {
|
||||
type: 'mcp',
|
||||
title: mcpTool.name,
|
||||
toolId: mcpTool.id,
|
||||
params: {
|
||||
serverId: mcpTool.serverId,
|
||||
serverUrl: server?.url,
|
||||
toolName: mcpTool.name,
|
||||
serverName: mcpTool.serverName,
|
||||
},
|
||||
isExpanded: true,
|
||||
usageControl: 'auto',
|
||||
schema: {
|
||||
...mcpTool.inputSchema,
|
||||
description: mcpTool.description,
|
||||
},
|
||||
}
|
||||
|
||||
onToolSelect(newTool)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded'
|
||||
style={{ background: mcpTool.bgColor }}
|
||||
onToolSelect(newTool)
|
||||
}}
|
||||
>
|
||||
<IconComponent icon={mcpTool.icon} className='h-[11px] w-[11px] text-white' />
|
||||
</div>
|
||||
<span className='truncate' title={`${mcpTool.name} (${mcpTool.serverName})`}>
|
||||
{mcpTool.name}
|
||||
</span>
|
||||
</ToolCommand.Item>
|
||||
))}
|
||||
<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>
|
||||
<span className='truncate' title={`${mcpTool.name} (${mcpTool.serverName})`}>
|
||||
{mcpTool.name}
|
||||
</span>
|
||||
</ToolCommand.Item>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+108
-5
@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Loader2, PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Combobox,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
PopoverSearch,
|
||||
PopoverSection,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { McpIcon } from '@/components/icons'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
@@ -55,9 +57,11 @@ import {
|
||||
type CustomTool as CustomToolDefinition,
|
||||
useCustomTools,
|
||||
} from '@/hooks/queries/custom-tools'
|
||||
import { useMcpServers } from '@/hooks/queries/mcp'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useMcpTools } from '@/hooks/use-mcp-tools'
|
||||
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
|
||||
import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import {
|
||||
formatParameterLabel,
|
||||
@@ -802,6 +806,66 @@ export function ToolInput({
|
||||
refreshTools,
|
||||
} = 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
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -1849,9 +1913,10 @@ export function ToolInput({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Display MCP tools */}
|
||||
{/* Display MCP tools (only from available servers) */}
|
||||
<McpToolsList
|
||||
mcpTools={mcpTools}
|
||||
mcpTools={availableMcpTools}
|
||||
mcpServers={mcpServers}
|
||||
searchQuery={searchQuery || ''}
|
||||
customFilter={customFilter}
|
||||
onToolSelect={handleMcpToolSelect}
|
||||
@@ -2040,9 +2105,46 @@ export function ToolInput({
|
||||
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{isCustomTool ? customToolTitle : tool.title}
|
||||
</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 className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
{supportsToolControl && (
|
||||
{supportsToolControl && !(isMcpTool && isMcpToolUnavailable(tool)) && (
|
||||
<Popover
|
||||
open={usageControlPopoverIndex === toolIndex}
|
||||
onOpenChange={(open) =>
|
||||
@@ -2386,9 +2488,10 @@ export function ToolInput({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Display MCP tools */}
|
||||
{/* Display MCP tools (only from available servers) */}
|
||||
<McpToolsList
|
||||
mcpTools={mcpTools}
|
||||
mcpTools={availableMcpTools}
|
||||
mcpServers={mcpServers}
|
||||
searchQuery={searchQuery || ''}
|
||||
customFilter={customFilter}
|
||||
onToolSelect={handleMcpToolSelect}
|
||||
|
||||
+17
-14
@@ -1,8 +1,5 @@
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Formats transport type for display (e.g., "streamable-http" -> "Streamable-HTTP").
|
||||
*/
|
||||
export function formatTransportLabel(transport: string): string {
|
||||
return transport
|
||||
.split('-')
|
||||
@@ -14,10 +11,10 @@ export function formatTransportLabel(transport: string): string {
|
||||
.join('-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats tools count and names for display.
|
||||
*/
|
||||
function formatToolsLabel(tools: any[]): string {
|
||||
function formatToolsLabel(tools: any[], connectionStatus?: string): string {
|
||||
if (connectionStatus === 'error') {
|
||||
return 'Unable to connect'
|
||||
}
|
||||
const count = tools.length
|
||||
const plural = count !== 1 ? 's' : ''
|
||||
const names = count > 0 ? `: ${tools.map((t) => t.name).join(', ')}` : ''
|
||||
@@ -29,35 +26,41 @@ interface ServerListItemProps {
|
||||
tools: any[]
|
||||
isDeleting: boolean
|
||||
isLoadingTools?: boolean
|
||||
isRefreshing?: boolean
|
||||
onRemove: () => void
|
||||
onViewDetails: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single MCP server list item with details and delete actions.
|
||||
*/
|
||||
export function ServerListItem({
|
||||
server,
|
||||
tools,
|
||||
isDeleting,
|
||||
isLoadingTools = false,
|
||||
isRefreshing = false,
|
||||
onRemove,
|
||||
onViewDetails,
|
||||
}: ServerListItemProps) {
|
||||
const transportLabel = formatTransportLabel(server.transport || 'http')
|
||||
const toolsLabel = formatToolsLabel(tools)
|
||||
const toolsLabel = formatToolsLabel(tools, server.connectionStatus)
|
||||
const isError = server.connectionStatus === 'error'
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<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'}
|
||||
</span>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>({transportLabel})</span>
|
||||
</div>
|
||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
||||
{isLoadingTools && tools.length === 0 ? 'Loading...' : toolsLabel}
|
||||
<p
|
||||
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>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[4px]'>
|
||||
|
||||
+135
-43
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input as EmcnInput,
|
||||
Modal,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui'
|
||||
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 {
|
||||
useCreateMcpServer,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
useMcpServers,
|
||||
useMcpToolsQuery,
|
||||
useRefreshMcpServer,
|
||||
useStoredMcpTools,
|
||||
} from '@/hooks/queries/mcp'
|
||||
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
|
||||
import type { InputFieldType, McpServerFormData, McpServerTestResult } from './components'
|
||||
@@ -44,6 +47,9 @@ interface McpServer {
|
||||
name?: string
|
||||
transport?: string
|
||||
url?: string
|
||||
connectionStatus?: 'connected' | 'disconnected' | 'error'
|
||||
lastError?: string | null
|
||||
lastConnected?: string
|
||||
}
|
||||
|
||||
const logger = createLogger('McpSettings')
|
||||
@@ -69,11 +75,15 @@ function getTestButtonLabel(
|
||||
return 'Test Connection'
|
||||
}
|
||||
|
||||
interface MCPProps {
|
||||
initialServerId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Settings component for managing Model Context Protocol servers.
|
||||
* Handles server CRUD operations, connection testing, and environment variable integration.
|
||||
*/
|
||||
export function MCP() {
|
||||
export function MCP({ initialServerId }: MCPProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
@@ -88,6 +98,7 @@ export function MCP() {
|
||||
isLoading: toolsLoading,
|
||||
isFetching: toolsFetching,
|
||||
} = useMcpToolsQuery(workspaceId)
|
||||
const { data: storedTools = [] } = useStoredMcpTools(workspaceId)
|
||||
const createServerMutation = useCreateMcpServer()
|
||||
const deleteServerMutation = useDeleteMcpServer()
|
||||
const refreshServerMutation = useRefreshMcpServer()
|
||||
@@ -106,7 +117,9 @@ export function MCP() {
|
||||
const [serverToDelete, setServerToDelete] = useState<{ id: string; name: 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 [envSearchTerm, setEnvSearchTerm] = useState('')
|
||||
@@ -114,10 +127,16 @@ export function MCP() {
|
||||
const [activeInputField, setActiveInputField] = useState<InputFieldType | null>(null)
|
||||
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
|
||||
|
||||
// Scroll position state for formatted text overlays
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
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.
|
||||
*/
|
||||
@@ -237,6 +256,7 @@ export function MCP() {
|
||||
|
||||
/**
|
||||
* Adds a new MCP server after validating and testing the connection.
|
||||
* Only creates the server if connection test succeeds.
|
||||
*/
|
||||
const handleAddServer = useCallback(async () => {
|
||||
if (!formData.name.trim()) return
|
||||
@@ -253,12 +273,12 @@ export function MCP() {
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
if (!testResult) {
|
||||
const result = await testConnection(serverConfig)
|
||||
if (!result.success) return
|
||||
}
|
||||
const connectionResult = await testConnection(serverConfig)
|
||||
|
||||
if (testResult && !testResult.success) return
|
||||
if (!connectionResult.success) {
|
||||
logger.error('Connection test failed, server not added:', connectionResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
@@ -279,15 +299,7 @@ export function MCP() {
|
||||
} finally {
|
||||
setIsAddingServer(false)
|
||||
}
|
||||
}, [
|
||||
formData,
|
||||
testResult,
|
||||
testConnection,
|
||||
createServerMutation,
|
||||
workspaceId,
|
||||
headersToRecord,
|
||||
resetForm,
|
||||
])
|
||||
}, [formData, testConnection, createServerMutation, workspaceId, headersToRecord, resetForm])
|
||||
|
||||
/**
|
||||
* Opens the delete confirmation dialog for an MCP server.
|
||||
@@ -297,9 +309,6 @@ export function MCP() {
|
||||
setShowDeleteDialog(true)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Confirms and executes the server deletion.
|
||||
*/
|
||||
const confirmDeleteServer = useCallback(async () => {
|
||||
if (!serverToDelete) return
|
||||
|
||||
@@ -399,14 +408,24 @@ export function MCP() {
|
||||
const handleRefreshServer = useCallback(
|
||||
async (serverId: string) => {
|
||||
try {
|
||||
setRefreshStatus('refreshing')
|
||||
setRefreshingServers((prev) => ({ ...prev, [serverId]: 'refreshing' }))
|
||||
await refreshServerMutation.mutateAsync({ workspaceId, serverId })
|
||||
logger.info(`Refreshed MCP server: ${serverId}`)
|
||||
setRefreshStatus('refreshed')
|
||||
setTimeout(() => setRefreshStatus('idle'), 2000)
|
||||
setRefreshingServers((prev) => ({ ...prev, [serverId]: 'refreshed' }))
|
||||
setTimeout(() => {
|
||||
setRefreshingServers((prev) => {
|
||||
const newState = { ...prev }
|
||||
delete newState[serverId]
|
||||
return newState
|
||||
})
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh MCP server:', error)
|
||||
setRefreshStatus('idle')
|
||||
setRefreshingServers((prev) => {
|
||||
const newState = { ...prev }
|
||||
delete newState[serverId]
|
||||
return newState
|
||||
})
|
||||
}
|
||||
},
|
||||
[refreshServerMutation, workspaceId]
|
||||
@@ -432,6 +451,53 @@ export function MCP() {
|
||||
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
|
||||
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) {
|
||||
const { server, tools } = selectedServer
|
||||
const transportLabel = formatTransportLabel(server.transport || 'http')
|
||||
@@ -463,6 +529,15 @@ export function MCP() {
|
||||
</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]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Tools ({tools.length})
|
||||
@@ -471,21 +546,37 @@ export function MCP() {
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>No tools available</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
|
||||
>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{tool.name}
|
||||
</p>
|
||||
{tool.description && (
|
||||
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{tools.map((tool) => {
|
||||
const issues = getStoredToolIssues(server.id, tool.name)
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{tool.name}
|
||||
</p>
|
||||
{issues.length > 0 && (
|
||||
<Badge
|
||||
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>
|
||||
@@ -496,11 +587,11 @@ export function MCP() {
|
||||
<Button
|
||||
onClick={() => handleRefreshServer(server.id)}
|
||||
variant='default'
|
||||
disabled={refreshStatus !== 'idle'}
|
||||
disabled={!!refreshingServers[server.id]}
|
||||
>
|
||||
{refreshStatus === 'refreshing'
|
||||
{refreshingServers[server.id] === 'refreshing'
|
||||
? 'Refreshing...'
|
||||
: refreshStatus === 'refreshed'
|
||||
: refreshingServers[server.id] === 'refreshed'
|
||||
? 'Refreshed'
|
||||
: 'Refresh Tools'}
|
||||
</Button>
|
||||
@@ -672,6 +763,7 @@ export function MCP() {
|
||||
tools={tools}
|
||||
isDeleting={deletingServers.has(server.id)}
|
||||
isLoadingTools={isLoadingTools}
|
||||
isRefreshing={refreshingServers[server.id] === 'refreshing'}
|
||||
onRemove={() => handleRemoveServer(server.id, server.name || 'this server')}
|
||||
onViewDetails={() => handleViewDetails(server.id)}
|
||||
/>
|
||||
|
||||
+22
-1
@@ -46,6 +46,7 @@ import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general
|
||||
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
|
||||
@@ -134,6 +135,8 @@ const allNavigationItems: NavigationItem[] = [
|
||||
|
||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
|
||||
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
|
||||
const { data: session } = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
@@ -247,6 +250,24 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
// React Query hook automatically loads and syncs settings
|
||||
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(() => {
|
||||
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
|
||||
setActiveSection(event.detail.tab)
|
||||
@@ -436,7 +457,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
{isBillingEnabled && activeSection === 'team' && <TeamManagement />}
|
||||
{activeSection === 'sso' && <SSO />}
|
||||
{activeSection === 'copilot' && <Copilot />}
|
||||
{activeSection === 'mcp' && <MCP />}
|
||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{activeSection === 'custom-tools' && <CustomTools />}
|
||||
</SModalMainBody>
|
||||
</SModalMain>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useSearchModalStore } from '@/stores/search-modal/store'
|
||||
import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
const logger = createLogger('Sidebar')
|
||||
@@ -88,7 +89,11 @@ export function Sidebar() {
|
||||
|
||||
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = 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 */
|
||||
useEffect(() => {
|
||||
@@ -219,7 +224,7 @@ export function Sidebar() {
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
onClick: () => setIsSettingsModalOpen(true),
|
||||
onClick: () => openSettingsModal(),
|
||||
},
|
||||
],
|
||||
[workspaceId]
|
||||
@@ -654,7 +659,10 @@ export function Sidebar() {
|
||||
|
||||
{/* Footer Navigation Modals */}
|
||||
<HelpModal open={isHelpModalOpen} onOpenChange={setIsHelpModalOpen} />
|
||||
<SettingsModal open={isSettingsModalOpen} onOpenChange={setIsSettingsModalOpen} />
|
||||
<SettingsModal
|
||||
open={isSettingsModalOpen}
|
||||
onOpenChange={(open) => (open ? openSettingsModal() : closeSettingsModal())}
|
||||
/>
|
||||
|
||||
{/* Hidden file input for workspace import */}
|
||||
<input
|
||||
|
||||
@@ -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 { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -72,6 +75,11 @@ export class BlockExecutor {
|
||||
|
||||
try {
|
||||
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) {
|
||||
blockLog.input = resolvedInputs
|
||||
}
|
||||
@@ -395,6 +403,60 @@ export class BlockExecutor {
|
||||
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(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode,
|
||||
|
||||
@@ -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 { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
@@ -35,19 +38,23 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
block: SerializedBlock,
|
||||
inputs: AgentInputs
|
||||
): Promise<BlockOutput | StreamingExecution> {
|
||||
const responseFormat = this.parseResponseFormat(inputs.responseFormat)
|
||||
const model = inputs.model || AGENT.DEFAULT_MODEL
|
||||
// Filter out unavailable MCP tools early so they don't appear in logs/inputs
|
||||
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 formattedTools = await this.formatTools(ctx, inputs.tools || [])
|
||||
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
|
||||
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({
|
||||
ctx,
|
||||
providerId,
|
||||
model,
|
||||
messages,
|
||||
inputs,
|
||||
inputs: filteredInputs,
|
||||
formattedTools,
|
||||
responseFormat,
|
||||
streaming: streamingConfig.shouldUseStreaming ?? false,
|
||||
@@ -58,10 +65,10 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
providerRequest,
|
||||
block,
|
||||
responseFormat,
|
||||
inputs
|
||||
filteredInputs
|
||||
)
|
||||
|
||||
await this.persistResponseToMemory(ctx, inputs, result, block.id)
|
||||
await this.persistResponseToMemory(ctx, filteredInputs, result, block.id)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -115,6 +122,53 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
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[]> {
|
||||
if (!Array.isArray(inputTools)) return []
|
||||
|
||||
@@ -304,6 +358,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
|
||||
/**
|
||||
* Process MCP tools using cached schemas from build time.
|
||||
* Note: Unavailable tools are already filtered by filterUnavailableMcpTools.
|
||||
*/
|
||||
private async processMcpToolsBatched(
|
||||
ctx: ExecutionContext,
|
||||
@@ -312,7 +367,6 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
if (mcpTools.length === 0) return []
|
||||
|
||||
const results: any[] = []
|
||||
|
||||
const toolsWithSchema: ToolInput[] = []
|
||||
const toolsNeedingDiscovery: ToolInput[] = []
|
||||
|
||||
|
||||
+102
-16
@@ -1,20 +1,17 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { McpServerStatusConfig } from '@/lib/mcp/types'
|
||||
|
||||
const logger = createLogger('McpQueries')
|
||||
|
||||
/**
|
||||
* Query key factories for MCP-related queries
|
||||
*/
|
||||
export type { McpServerStatusConfig }
|
||||
|
||||
export const mcpKeys = {
|
||||
all: ['mcp'] as const,
|
||||
servers: (workspaceId: string) => [...mcpKeys.all, 'servers', workspaceId] as const,
|
||||
tools: (workspaceId: string) => [...mcpKeys.all, 'tools', workspaceId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Server Types
|
||||
*/
|
||||
export interface McpServer {
|
||||
id: string
|
||||
workspaceId: string
|
||||
@@ -25,9 +22,11 @@ export interface McpServer {
|
||||
headers?: Record<string, string>
|
||||
enabled: boolean
|
||||
connectionStatus?: 'connected' | 'disconnected' | 'error'
|
||||
lastError?: string
|
||||
lastError?: string | null
|
||||
statusConfig?: McpServerStatusConfig
|
||||
toolCount?: number
|
||||
lastToolsRefresh?: string
|
||||
lastConnected?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt?: string
|
||||
@@ -86,8 +85,13 @@ export function useMcpServers(workspaceId: string) {
|
||||
/**
|
||||
* Fetch MCP tools for a workspace
|
||||
*/
|
||||
async function fetchMcpTools(workspaceId: string): Promise<McpTool[]> {
|
||||
const response = await fetch(`/api/mcp/tools/discover?workspaceId=${workspaceId}`)
|
||||
async function fetchMcpTools(workspaceId: string, forceRefresh = false): Promise<McpTool[]> {
|
||||
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
|
||||
if (response.status === 404) {
|
||||
@@ -159,14 +163,43 @@ export function useCreateMcpServer() {
|
||||
return {
|
||||
...serverData,
|
||||
id: serverId,
|
||||
connectionStatus: 'disconnected' as const,
|
||||
connectionStatus: 'connected' as const,
|
||||
serverId,
|
||||
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.tools(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -213,7 +246,7 @@ export function useDeleteMcpServer() {
|
||||
interface UpdateMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
updates: Partial<McpServerConfig>
|
||||
updates: Partial<McpServerConfig & { enabled?: boolean }>
|
||||
}
|
||||
|
||||
export function useUpdateMcpServer() {
|
||||
@@ -221,8 +254,20 @@ export function useUpdateMcpServer() {
|
||||
|
||||
return useMutation({
|
||||
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}`)
|
||||
return { serverId, updates }
|
||||
return data.data?.server
|
||||
},
|
||||
onMutate: async ({ workspaceId, serverId, updates }) => {
|
||||
await queryClient.cancelQueries({ queryKey: mcpKeys.servers(workspaceId) })
|
||||
@@ -249,6 +294,7 @@ export function useUpdateMcpServer() {
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
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}`)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
onSuccess: async (_data, variables) => {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,14 +34,19 @@ export function useMcpServerTest() {
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false)
|
||||
|
||||
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) {
|
||||
const result: McpServerTestResult = {
|
||||
success: false,
|
||||
message: 'Missing required configuration',
|
||||
error: 'Please provide server name, transport method, and workspace ID',
|
||||
}
|
||||
setTestResult(result)
|
||||
if (!silent) setTestResult(result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -51,12 +56,14 @@ export function useMcpServerTest() {
|
||||
message: 'Missing server URL',
|
||||
error: 'Please provide a server URL for HTTP/SSE transport',
|
||||
}
|
||||
setTestResult(result)
|
||||
if (!silent) setTestResult(result)
|
||||
return result
|
||||
}
|
||||
|
||||
setIsTestingConnection(true)
|
||||
setTestResult(null)
|
||||
if (!silent) {
|
||||
setIsTestingConnection(true)
|
||||
setTestResult(null)
|
||||
}
|
||||
|
||||
try {
|
||||
const cleanConfig = {
|
||||
@@ -88,14 +95,14 @@ export function useMcpServerTest() {
|
||||
error: result.data.error,
|
||||
warnings: result.data.warnings,
|
||||
}
|
||||
setTestResult(testResult)
|
||||
if (!silent) setTestResult(testResult)
|
||||
logger.error('MCP server test failed:', result.data.error)
|
||||
return testResult
|
||||
}
|
||||
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)
|
||||
return result.data || result
|
||||
} catch (error) {
|
||||
@@ -105,11 +112,11 @@ export function useMcpServerTest() {
|
||||
message: 'Connection failed',
|
||||
error: errorMessage,
|
||||
}
|
||||
setTestResult(result)
|
||||
if (!silent) setTestResult(result)
|
||||
logger.error('MCP server test failed:', errorMessage)
|
||||
return result
|
||||
} finally {
|
||||
setIsTestingConnection(false)
|
||||
if (!silent) setIsTestingConnection(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -108,7 +108,7 @@ export class McpClient {
|
||||
this.connectionStatus.lastError = errorMessage
|
||||
this.isConnected = false
|
||||
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[]> {
|
||||
if (!this.isConnected) {
|
||||
throw new McpConnectionError('Not connected to server', this.config.id)
|
||||
throw new McpConnectionError('Not connected to server', this.config.name)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -170,7 +170,7 @@ export class McpClient {
|
||||
*/
|
||||
async callTool(toolCall: McpToolCall): Promise<McpToolResult> {
|
||||
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 = {
|
||||
@@ -217,7 +217,7 @@ export class McpClient {
|
||||
*/
|
||||
async ping(): Promise<{ _meta?: Record<string, any> }> {
|
||||
if (!this.isConnected) {
|
||||
throw new McpConnectionError('Not connected to server', this.config.id)
|
||||
throw new McpConnectionError('Not connected to server', this.config.name)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
+135
-185
@@ -10,8 +10,14 @@ import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { McpClient } from '@/lib/mcp/client'
|
||||
import {
|
||||
createMcpCacheAdapter,
|
||||
getMcpCacheType,
|
||||
type McpCacheStorageAdapter,
|
||||
} from '@/lib/mcp/storage'
|
||||
import type {
|
||||
McpServerConfig,
|
||||
McpServerStatusConfig,
|
||||
McpServerSummary,
|
||||
McpTool,
|
||||
McpToolCall,
|
||||
@@ -22,154 +28,21 @@ import { MCP_CONSTANTS } from '@/lib/mcp/utils'
|
||||
|
||||
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 {
|
||||
private toolCache = new Map<string, ToolCache>()
|
||||
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT // 30 seconds
|
||||
private readonly maxCacheSize = MCP_CONSTANTS.MAX_CACHE_SIZE // 1000
|
||||
private cleanupInterval: NodeJS.Timeout | null = null
|
||||
private cacheHits = 0
|
||||
private cacheMisses = 0
|
||||
private entriesEvicted = 0
|
||||
private cacheAdapter: McpCacheStorageAdapter
|
||||
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT // 5 minutes
|
||||
|
||||
constructor() {
|
||||
this.startPeriodicCleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
this.cacheAdapter = createMcpCacheAdapter()
|
||||
logger.info(`MCP Service initialized with ${getMcpCacheType()} cache`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of the service and cleanup resources
|
||||
*/
|
||||
dispose(): void {
|
||||
this.stopPeriodicCleanup()
|
||||
this.toolCache.clear()
|
||||
logger.info('MCP Service disposed and cleanup stopped')
|
||||
this.cacheAdapter.dispose()
|
||||
logger.info('MCP Service disposed')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
*/
|
||||
@@ -399,10 +347,14 @@ class McpService {
|
||||
|
||||
try {
|
||||
if (!forceRefresh) {
|
||||
const cached = this.getCacheEntry(cacheKey)
|
||||
if (cached && cached.expiry > new Date()) {
|
||||
logger.debug(`[${requestId}] Using cached tools for user ${userId}`)
|
||||
return cached.tools
|
||||
try {
|
||||
const cached = await this.cacheAdapter.get(cacheKey)
|
||||
if (cached) {
|
||||
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(
|
||||
`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`
|
||||
)
|
||||
return tools
|
||||
return { serverId: config.id, tools }
|
||||
} finally {
|
||||
await client.disconnect()
|
||||
}
|
||||
@@ -433,20 +385,44 @@ class McpService {
|
||||
)
|
||||
|
||||
let failedCount = 0
|
||||
const statusUpdates: Promise<void>[] = []
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const server = servers[index]
|
||||
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 {
|
||||
failedCount++
|
||||
const errorMessage =
|
||||
result.reason instanceof Error ? result.reason.message : 'Unknown error'
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to discover tools from server ${servers[index].name}:`,
|
||||
`[${requestId}] Failed to discover tools from server ${server.name}:`,
|
||||
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) {
|
||||
this.setCacheEntry(cacheKey, allTools)
|
||||
try {
|
||||
await this.cacheAdapter.set(cacheKey, allTools, this.cacheTimeout)
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Cache write failed:`, error)
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`[${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
|
||||
*/
|
||||
clearCache(workspaceId?: string): void {
|
||||
if (workspaceId) {
|
||||
const workspaceCacheKey = `workspace:${workspaceId}`
|
||||
this.toolCache.delete(workspaceCacheKey)
|
||||
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId}`)
|
||||
} else {
|
||||
this.toolCache.clear()
|
||||
this.cacheHits = 0
|
||||
this.cacheMisses = 0
|
||||
this.entriesEvicted = 0
|
||||
logger.debug('Cleared all MCP tool cache and reset statistics')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
async clearCache(workspaceId?: string): Promise<void> {
|
||||
try {
|
||||
if (workspaceId) {
|
||||
const workspaceCacheKey = `workspace:${workspaceId}`
|
||||
await this.cacheAdapter.delete(workspaceCacheKey)
|
||||
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId}`)
|
||||
} else {
|
||||
await this.cacheAdapter.clear()
|
||||
logger.debug('Cleared all MCP tool cache')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to clear cache:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,11 @@
|
||||
// Modern MCP uses Streamable HTTP which handles both HTTP POST and SSE responses
|
||||
export type McpTransport = 'streamable-http'
|
||||
|
||||
export interface McpServerStatusConfig {
|
||||
consecutiveFailures: number
|
||||
lastSuccessfulDiscovery: string | null
|
||||
}
|
||||
|
||||
export interface McpServerConfig {
|
||||
id: string
|
||||
name: string
|
||||
@@ -20,6 +25,7 @@ export interface McpServerConfig {
|
||||
timeout?: number
|
||||
retries?: number
|
||||
enabled?: boolean
|
||||
statusConfig?: McpServerStatusConfig
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
@@ -113,8 +119,8 @@ export class McpError extends Error {
|
||||
}
|
||||
|
||||
export class McpConnectionError extends McpError {
|
||||
constructor(message: string, serverId: string) {
|
||||
super(`MCP Connection Error for server ${serverId}: ${message}`)
|
||||
constructor(message: string, serverName: string) {
|
||||
super(`Failed to connect to "${serverName}": ${message}`)
|
||||
this.name = 'McpConnectionError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ import type { McpApiResponse } from '@/lib/mcp/types'
|
||||
*/
|
||||
export const MCP_CONSTANTS = {
|
||||
EXECUTION_TIMEOUT: 60000,
|
||||
CACHE_TIMEOUT: 30 * 1000,
|
||||
CACHE_TIMEOUT: 5 * 60 * 1000, // 5 minutes
|
||||
DEFAULT_RETRIES: 3,
|
||||
DEFAULT_CONNECTION_TIMEOUT: 30000,
|
||||
MAX_CACHE_SIZE: 1000,
|
||||
MAX_CONSECUTIVE_FAILURES: 3,
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -1001,7 +1001,7 @@ export function supportsToolUsageControl(provider: string): boolean {
|
||||
* Prepare tool execution parameters, separating tool parameters from system parameters
|
||||
*/
|
||||
export function prepareToolExecution(
|
||||
tool: { params?: Record<string, any> },
|
||||
tool: { params?: Record<string, any>; parameters?: Record<string, any> },
|
||||
llmArgs: Record<string, any>,
|
||||
request: {
|
||||
workflowId?: string
|
||||
@@ -1051,6 +1051,8 @@ export function prepareToolExecution(
|
||||
...(request.workflowVariables ? { workflowVariables: request.workflowVariables } : {}),
|
||||
...(request.blockData ? { blockData: request.blockData } : {}),
|
||||
...(request.blockNameMapping ? { blockNameMapping: request.blockNameMapping } : {}),
|
||||
// Pass tool schema for MCP tools to skip discovery
|
||||
...(tool.parameters ? { _toolSchema: tool.parameters } : {}),
|
||||
}
|
||||
|
||||
return { toolParams, executionParams }
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}))
|
||||
+11
-1
@@ -107,6 +107,7 @@ const MCP_SYSTEM_PARAMETERS = new Set([
|
||||
'workflowVariables',
|
||||
'blockData',
|
||||
'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,
|
||||
toolName,
|
||||
arguments: toolArguments,
|
||||
@@ -987,6 +991,11 @@ async function executeMcpTool(
|
||||
workspaceId, // Pass workspace context for scoping
|
||||
}
|
||||
|
||||
// Include schema to skip discovery on execution
|
||||
if (toolSchema) {
|
||||
requestBody.toolSchema = toolSchema
|
||||
}
|
||||
|
||||
const body = JSON.stringify(requestBody)
|
||||
|
||||
// Check request body size before sending
|
||||
@@ -995,6 +1004,7 @@ async function executeMcpTool(
|
||||
logger.info(`[${actualRequestId}] Making MCP tool request to ${toolName} on ${serverId}`, {
|
||||
hasWorkspaceId: !!workspaceId,
|
||||
hasWorkflowId: !!workflowId,
|
||||
hasToolSchema: !!toolSchema,
|
||||
})
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/mcp/tools/execute`, {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "mcp_servers" ADD COLUMN "status_config" jsonb DEFAULT '{}';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -855,6 +855,13 @@
|
||||
"when": 1765587157593,
|
||||
"tag": "0122_pale_absorbing_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 123,
|
||||
"version": "7",
|
||||
"when": 1765932898404,
|
||||
"tag": "0123_windy_lockheed",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1547,6 +1547,8 @@ export const mcpServers = pgTable(
|
||||
connectionStatus: text('connection_status').default('disconnected'),
|
||||
lastError: text('last_error'),
|
||||
|
||||
statusConfig: jsonb('status_config').default('{}'),
|
||||
|
||||
toolCount: integer('tool_count').default(0),
|
||||
lastToolsRefresh: timestamp('last_tools_refresh'),
|
||||
totalRequests: integer('total_requests').default(0),
|
||||
|
||||
Reference in New Issue
Block a user