mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 07:27:57 -05: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 { 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,
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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` })
|
||||||
|
|||||||
103
apps/sim/app/api/mcp/tools/stored/route.ts
Normal file
103
apps/sim/app/api/mcp/tools/stored/route.ts
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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]'>
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[] = []
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/sim/lib/mcp/storage/adapter.ts
Normal file
14
apps/sim/lib/mcp/storage/adapter.ts
Normal 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
|
||||||
|
}
|
||||||
53
apps/sim/lib/mcp/storage/factory.ts
Normal file
53
apps/sim/lib/mcp/storage/factory.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/sim/lib/mcp/storage/index.ts
Normal file
4
apps/sim/lib/mcp/storage/index.ts
Normal 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'
|
||||||
103
apps/sim/lib/mcp/storage/memory-cache.ts
Normal file
103
apps/sim/lib/mcp/storage/memory-cache.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
96
apps/sim/lib/mcp/storage/redis-cache.ts
Normal file
96
apps/sim/lib/mcp/storage/redis-cache.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
129
apps/sim/lib/mcp/tool-validation.ts
Normal file
129
apps/sim/lib/mcp/tool-validation.ts
Normal 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'
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
51
apps/sim/stores/settings-modal/store.ts
Normal file
51
apps/sim/stores/settings-modal/store.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
@@ -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`, {
|
||||||
|
|||||||
1
packages/db/migrations/0123_windy_lockheed.sql
Normal file
1
packages/db/migrations/0123_windy_lockheed.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "mcp_servers" ADD COLUMN "status_config" jsonb DEFAULT '{}';
|
||||||
7722
packages/db/migrations/meta/0123_snapshot.json
Normal file
7722
packages/db/migrations/meta/0123_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user