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

* feat(mcp): improve cache practice

* restructure mcps fetching, caching, UX indicators

* fix schema

* styling improvements

* fix tooltips and render issue

* fix loading sequence + add redis

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Vikhyath Mondreti
2025-12-16 21:23:18 -08:00
committed by GitHub
parent b7228d57f7
commit de330d80f5
31 changed files with 9087 additions and 337 deletions
@@ -5,6 +5,7 @@ import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpServerStatusConfig } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpServerRefreshAPI')
@@ -50,6 +51,12 @@ export const POST = withMcpAuth<{ id: string }>('read')(
let toolCount = 0
let lastError: string | null = null
const currentStatusConfig: McpServerStatusConfig =
(server.statusConfig as McpServerStatusConfig | null) ?? {
consecutiveFailures: 0,
lastSuccessfulDiscovery: null,
}
try {
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
connectionStatus = 'connected'
@@ -63,20 +70,40 @@ export const POST = withMcpAuth<{ id: string }>('read')(
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
}
const now = new Date()
const newStatusConfig =
connectionStatus === 'connected'
? { consecutiveFailures: 0, lastSuccessfulDiscovery: now.toISOString() }
: {
consecutiveFailures: currentStatusConfig.consecutiveFailures + 1,
lastSuccessfulDiscovery: currentStatusConfig.lastSuccessfulDiscovery,
}
const [refreshedServer] = await db
.update(mcpServers)
.set({
lastToolsRefresh: new Date(),
lastToolsRefresh: now,
connectionStatus,
lastError,
lastConnected: connectionStatus === 'connected' ? new Date() : server.lastConnected,
lastConnected: connectionStatus === 'connected' ? now : server.lastConnected,
toolCount,
updatedAt: new Date(),
statusConfig: newStatusConfig,
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
.returning()
logger.info(`[${requestId}] Successfully refreshed MCP server: ${serverId}`)
if (connectionStatus === 'connected') {
logger.info(
`[${requestId}] Successfully refreshed MCP server: ${serverId} (${toolCount} tools)`
)
await mcpService.clearCache(workspaceId)
} else {
logger.warn(
`[${requestId}] Refresh completed for MCP server ${serverId} but connection failed: ${lastError}`
)
}
return createMcpSuccessResponse({
status: connectionStatus,
toolCount,
+19 -2
View File
@@ -48,6 +48,19 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
// Remove workspaceId from body to prevent it from being updated
const { workspaceId: _, ...updateData } = body
// Get the current server to check if URL is changing
const [currentServer] = await db
.select({ url: mcpServers.url })
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
const [updatedServer] = await db
.update(mcpServers)
.set({
@@ -71,8 +84,12 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
)
}
// Clear MCP service cache after update
mcpService.clearCache(workspaceId)
// Only clear cache if URL changed (requires re-discovery)
const urlChanged = body.url && currentServer?.url !== body.url
if (urlChanged) {
await mcpService.clearCache(workspaceId)
logger.info(`[${requestId}] Cleared cache due to URL change`)
}
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
return createMcpSuccessResponse({ server: updatedServer })
+7 -3
View File
@@ -117,12 +117,14 @@ export const POST = withMcpAuth('write')(
timeout: body.timeout || 30000,
retries: body.retries || 3,
enabled: body.enabled !== false,
connectionStatus: 'connected',
lastConnected: new Date(),
updatedAt: new Date(),
deletedAt: null,
})
.where(eq(mcpServers.id, serverId))
mcpService.clearCache(workspaceId)
await mcpService.clearCache(workspaceId)
logger.info(
`[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})`
@@ -145,12 +147,14 @@ export const POST = withMcpAuth('write')(
timeout: body.timeout || 30000,
retries: body.retries || 3,
enabled: body.enabled !== false,
connectionStatus: 'connected',
lastConnected: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
mcpService.clearCache(workspaceId)
await mcpService.clearCache(workspaceId)
logger.info(
`[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})`
@@ -212,7 +216,7 @@ export const DELETE = withMcpAuth('admin')(
)
}
mcpService.clearCache(workspaceId)
await mcpService.clearCache(workspaceId)
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
+103
View File
@@ -0,0 +1,103 @@
import { db } from '@sim/db'
import { workflow, workflowBlocks } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { withMcpAuth } from '@/lib/mcp/middleware'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpStoredToolsAPI')
export const dynamic = 'force-dynamic'
interface StoredMcpTool {
workflowId: string
workflowName: string
serverId: string
serverUrl?: string
toolName: string
schema?: Record<string, unknown>
}
/**
* GET - Get all stored MCP tools from workflows in the workspace
*
* Scans all workflows in the workspace and extracts MCP tools that have been
* added to agent blocks. Returns the stored state of each tool for comparison
* against current server state.
*/
export const GET = withMcpAuth('read')(
async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`)
// Get all workflows in workspace
const workflows = await db
.select({
id: workflow.id,
name: workflow.name,
})
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
const workflowMap = new Map(workflows.map((w) => [w.id, w.name]))
const workflowIds = workflows.map((w) => w.id)
if (workflowIds.length === 0) {
return createMcpSuccessResponse({ tools: [] })
}
// Get all agent blocks from these workflows
const agentBlocks = await db
.select({
workflowId: workflowBlocks.workflowId,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.type, 'agent'))
const storedTools: StoredMcpTool[] = []
for (const block of agentBlocks) {
if (!workflowMap.has(block.workflowId)) continue
const subBlocks = block.subBlocks as Record<string, unknown> | null
if (!subBlocks) continue
const toolsSubBlock = subBlocks.tools as Record<string, unknown> | undefined
const toolsValue = toolsSubBlock?.value
if (!toolsValue || !Array.isArray(toolsValue)) continue
for (const tool of toolsValue) {
if (tool.type !== 'mcp') continue
const params = tool.params as Record<string, unknown> | undefined
if (!params?.serverId || !params?.toolName) continue
storedTools.push({
workflowId: block.workflowId,
workflowName: workflowMap.get(block.workflowId) || 'Untitled',
serverId: params.serverId as string,
serverUrl: params.serverUrl as string | undefined,
toolName: params.toolName as string,
schema: tool.schema as Record<string, unknown> | undefined,
})
}
}
logger.info(
`[${requestId}] Found ${storedTools.length} stored MCP tools across ${workflows.length} workflows`
)
return createMcpSuccessResponse({ tools: storedTools })
} catch (error) {
logger.error(`[${requestId}] Error fetching stored MCP tools:`, error)
return createMcpErrorResponse(
error instanceof Error ? error : new Error('Failed to fetch stored MCP tools'),
'Failed to fetch stored MCP tools',
500
)
}
}
)
@@ -18,12 +18,18 @@ interface McpTool {
inputSchema?: any
}
interface McpServer {
id: string
url?: string
}
interface StoredTool {
type: 'mcp'
title: string
toolId: string
params: {
serverId: string
serverUrl?: string
toolName: string
serverName: string
}
@@ -34,6 +40,7 @@ interface StoredTool {
interface McpToolsListProps {
mcpTools: McpTool[]
mcpServers?: McpServer[]
searchQuery: string
customFilter: (name: string, query: string) => number
onToolSelect: (tool: StoredTool) => void
@@ -45,6 +52,7 @@ interface McpToolsListProps {
*/
export function McpToolsList({
mcpTools,
mcpServers = [],
searchQuery,
customFilter,
onToolSelect,
@@ -59,44 +67,48 @@ export function McpToolsList({
return (
<>
<PopoverSection>MCP Tools</PopoverSection>
{filteredTools.map((mcpTool) => (
<ToolCommand.Item
key={mcpTool.id}
value={mcpTool.name}
onSelect={() => {
if (disabled) return
{filteredTools.map((mcpTool) => {
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
return (
<ToolCommand.Item
key={mcpTool.id}
value={mcpTool.name}
onSelect={() => {
if (disabled) return
const newTool: StoredTool = {
type: 'mcp',
title: mcpTool.name,
toolId: mcpTool.id,
params: {
serverId: mcpTool.serverId,
toolName: mcpTool.name,
serverName: mcpTool.serverName,
},
isExpanded: true,
usageControl: 'auto',
schema: {
...mcpTool.inputSchema,
description: mcpTool.description,
},
}
const newTool: StoredTool = {
type: 'mcp',
title: mcpTool.name,
toolId: mcpTool.id,
params: {
serverId: mcpTool.serverId,
serverUrl: server?.url,
toolName: mcpTool.name,
serverName: mcpTool.serverName,
},
isExpanded: true,
usageControl: 'auto',
schema: {
...mcpTool.inputSchema,
description: mcpTool.description,
},
}
onToolSelect(newTool)
}}
>
<div
className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded'
style={{ background: mcpTool.bgColor }}
onToolSelect(newTool)
}}
>
<IconComponent icon={mcpTool.icon} className='h-[11px] w-[11px] text-white' />
</div>
<span className='truncate' title={`${mcpTool.name} (${mcpTool.serverName})`}>
{mcpTool.name}
</span>
</ToolCommand.Item>
))}
<div
className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded'
style={{ background: mcpTool.bgColor }}
>
<IconComponent icon={mcpTool.icon} className='h-[11px] w-[11px] text-white' />
</div>
<span className='truncate' title={`${mcpTool.name} (${mcpTool.serverName})`}>
{mcpTool.name}
</span>
</ToolCommand.Item>
)
})}
</>
)
}
@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'
import { Loader2, PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Combobox,
Popover,
PopoverContent,
@@ -12,6 +13,7 @@ import {
PopoverSearch,
PopoverSection,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { McpIcon } from '@/components/icons'
import { Switch } from '@/components/ui/switch'
@@ -55,9 +57,11 @@ import {
type CustomTool as CustomToolDefinition,
useCustomTools,
} from '@/hooks/queries/custom-tools'
import { useMcpServers } from '@/hooks/queries/mcp'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useMcpTools } from '@/hooks/use-mcp-tools'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
import { useSettingsModalStore } from '@/stores/settings-modal/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import {
formatParameterLabel,
@@ -802,6 +806,66 @@ export function ToolInput({
refreshTools,
} = useMcpTools(workspaceId)
const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId)
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
const mcpDataLoading = mcpLoading || mcpServersLoading
/**
* Returns issue info for an MCP tool using shared validation logic.
*/
const getMcpToolIssue = useCallback(
(tool: StoredTool) => {
if (tool.type !== 'mcp') return null
const { getMcpToolIssue: validateTool } = require('@/lib/mcp/tool-validation')
return validateTool(
{
serverId: tool.params?.serverId as string,
serverUrl: tool.params?.serverUrl as string | undefined,
toolName: tool.params?.toolName as string,
schema: tool.schema,
},
mcpServers.map((s) => ({
id: s.id,
url: s.url,
connectionStatus: s.connectionStatus,
lastError: s.lastError,
})),
mcpTools.map((t) => ({
serverId: t.serverId,
name: t.name,
inputSchema: t.inputSchema,
}))
)
},
[mcpTools, mcpServers]
)
const isMcpToolUnavailable = useCallback(
(tool: StoredTool): boolean => {
const { isToolUnavailable } = require('@/lib/mcp/tool-validation')
return isToolUnavailable(getMcpToolIssue(tool))
},
[getMcpToolIssue]
)
const hasMcpToolIssue = useCallback(
(tool: StoredTool): boolean => {
return getMcpToolIssue(tool) !== null
},
[getMcpToolIssue]
)
// Filter out MCP tools from unavailable servers for the dropdown
const availableMcpTools = useMemo(() => {
return mcpTools.filter((mcpTool) => {
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
// Only include tools from connected servers
return server && server.connectionStatus === 'connected'
})
}, [mcpTools, mcpServers])
// Reset search query when popover opens
useEffect(() => {
if (open) {
@@ -1849,9 +1913,10 @@ export function ToolInput({
)
})()}
{/* Display MCP tools */}
{/* Display MCP tools (only from available servers) */}
<McpToolsList
mcpTools={mcpTools}
mcpTools={availableMcpTools}
mcpServers={mcpServers}
searchQuery={searchQuery || ''}
customFilter={customFilter}
onToolSelect={handleMcpToolSelect}
@@ -2040,9 +2105,46 @@ export function ToolInput({
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{isCustomTool ? customToolTitle : tool.title}
</span>
{isMcpTool &&
!mcpDataLoading &&
(() => {
const issue = getMcpToolIssue(tool)
if (!issue) return null
const { getIssueBadgeLabel } = require('@/lib/mcp/tool-validation')
const serverId = tool.params?.serverId
return (
<div
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
openSettingsModal({ section: 'mcp', mcpServerId: serverId })
}}
>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='outline'
className='cursor-pointer transition-colors hover:bg-[var(--warning)]/10'
style={{
borderColor: 'var(--warning)',
color: 'var(--warning)',
}}
>
{getIssueBadgeLabel(issue)}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<span className='text-sm'>
{issue.message} · Click to open settings
</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
)
})()}
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{supportsToolControl && (
{supportsToolControl && !(isMcpTool && isMcpToolUnavailable(tool)) && (
<Popover
open={usageControlPopoverIndex === toolIndex}
onOpenChange={(open) =>
@@ -2386,9 +2488,10 @@ export function ToolInput({
)
})()}
{/* Display MCP tools */}
{/* Display MCP tools (only from available servers) */}
<McpToolsList
mcpTools={mcpTools}
mcpTools={availableMcpTools}
mcpServers={mcpServers}
searchQuery={searchQuery || ''}
customFilter={customFilter}
onToolSelect={handleMcpToolSelect}
@@ -1,8 +1,5 @@
import { Button } from '@/components/emcn'
/**
* Formats transport type for display (e.g., "streamable-http" -> "Streamable-HTTP").
*/
export function formatTransportLabel(transport: string): string {
return transport
.split('-')
@@ -14,10 +11,10 @@ export function formatTransportLabel(transport: string): string {
.join('-')
}
/**
* Formats tools count and names for display.
*/
function formatToolsLabel(tools: any[]): string {
function formatToolsLabel(tools: any[], connectionStatus?: string): string {
if (connectionStatus === 'error') {
return 'Unable to connect'
}
const count = tools.length
const plural = count !== 1 ? 's' : ''
const names = count > 0 ? `: ${tools.map((t) => t.name).join(', ')}` : ''
@@ -29,35 +26,41 @@ interface ServerListItemProps {
tools: any[]
isDeleting: boolean
isLoadingTools?: boolean
isRefreshing?: boolean
onRemove: () => void
onViewDetails: () => void
}
/**
* Renders a single MCP server list item with details and delete actions.
*/
export function ServerListItem({
server,
tools,
isDeleting,
isLoadingTools = false,
isRefreshing = false,
onRemove,
onViewDetails,
}: ServerListItemProps) {
const transportLabel = formatTransportLabel(server.transport || 'http')
const toolsLabel = formatToolsLabel(tools)
const toolsLabel = formatToolsLabel(tools, server.connectionStatus)
const isError = server.connectionStatus === 'error'
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<span className='max-w-[280px] truncate font-medium text-[14px]'>
<span className='max-w-[200px] truncate font-medium text-[14px]'>
{server.name || 'Unnamed Server'}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>({transportLabel})</span>
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{isLoadingTools && tools.length === 0 ? 'Loading...' : toolsLabel}
<p
className={`truncate text-[13px] ${isError ? 'text-red-500 dark:text-red-400' : 'text-[var(--text-muted)]'}`}
>
{isRefreshing
? 'Refreshing...'
: isLoadingTools && tools.length === 0
? 'Loading...'
: toolsLabel}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
@@ -1,9 +1,10 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
Input as EmcnInput,
Modal,
@@ -14,6 +15,7 @@ import {
} from '@/components/emcn'
import { Input } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { getIssueBadgeLabel, getMcpToolIssue, type McpToolIssue } from '@/lib/mcp/tool-validation'
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import {
useCreateMcpServer,
@@ -21,6 +23,7 @@ import {
useMcpServers,
useMcpToolsQuery,
useRefreshMcpServer,
useStoredMcpTools,
} from '@/hooks/queries/mcp'
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
import type { InputFieldType, McpServerFormData, McpServerTestResult } from './components'
@@ -44,6 +47,9 @@ interface McpServer {
name?: string
transport?: string
url?: string
connectionStatus?: 'connected' | 'disconnected' | 'error'
lastError?: string | null
lastConnected?: string
}
const logger = createLogger('McpSettings')
@@ -69,11 +75,15 @@ function getTestButtonLabel(
return 'Test Connection'
}
interface MCPProps {
initialServerId?: string | null
}
/**
* MCP Settings component for managing Model Context Protocol servers.
* Handles server CRUD operations, connection testing, and environment variable integration.
*/
export function MCP() {
export function MCP({ initialServerId }: MCPProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -88,6 +98,7 @@ export function MCP() {
isLoading: toolsLoading,
isFetching: toolsFetching,
} = useMcpToolsQuery(workspaceId)
const { data: storedTools = [] } = useStoredMcpTools(workspaceId)
const createServerMutation = useCreateMcpServer()
const deleteServerMutation = useDeleteMcpServer()
const refreshServerMutation = useRefreshMcpServer()
@@ -106,7 +117,9 @@ export function MCP() {
const [serverToDelete, setServerToDelete] = useState<{ id: string; name: string } | null>(null)
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
const [refreshStatus, setRefreshStatus] = useState<'idle' | 'refreshing' | 'refreshed'>('idle')
const [refreshingServers, setRefreshingServers] = useState<
Record<string, 'refreshing' | 'refreshed'>
>({})
const [showEnvVars, setShowEnvVars] = useState(false)
const [envSearchTerm, setEnvSearchTerm] = useState('')
@@ -114,10 +127,16 @@ export function MCP() {
const [activeInputField, setActiveInputField] = useState<InputFieldType | null>(null)
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
// Scroll position state for formatted text overlays
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
// Auto-select server when initialServerId is provided
useEffect(() => {
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
setSelectedServerId(initialServerId)
}
}, [initialServerId, servers])
/**
* Resets environment variable dropdown state.
*/
@@ -237,6 +256,7 @@ export function MCP() {
/**
* Adds a new MCP server after validating and testing the connection.
* Only creates the server if connection test succeeds.
*/
const handleAddServer = useCallback(async () => {
if (!formData.name.trim()) return
@@ -253,12 +273,12 @@ export function MCP() {
workspaceId,
}
if (!testResult) {
const result = await testConnection(serverConfig)
if (!result.success) return
}
const connectionResult = await testConnection(serverConfig)
if (testResult && !testResult.success) return
if (!connectionResult.success) {
logger.error('Connection test failed, server not added:', connectionResult.error)
return
}
await createServerMutation.mutateAsync({
workspaceId,
@@ -279,15 +299,7 @@ export function MCP() {
} finally {
setIsAddingServer(false)
}
}, [
formData,
testResult,
testConnection,
createServerMutation,
workspaceId,
headersToRecord,
resetForm,
])
}, [formData, testConnection, createServerMutation, workspaceId, headersToRecord, resetForm])
/**
* Opens the delete confirmation dialog for an MCP server.
@@ -297,9 +309,6 @@ export function MCP() {
setShowDeleteDialog(true)
}, [])
/**
* Confirms and executes the server deletion.
*/
const confirmDeleteServer = useCallback(async () => {
if (!serverToDelete) return
@@ -399,14 +408,24 @@ export function MCP() {
const handleRefreshServer = useCallback(
async (serverId: string) => {
try {
setRefreshStatus('refreshing')
setRefreshingServers((prev) => ({ ...prev, [serverId]: 'refreshing' }))
await refreshServerMutation.mutateAsync({ workspaceId, serverId })
logger.info(`Refreshed MCP server: ${serverId}`)
setRefreshStatus('refreshed')
setTimeout(() => setRefreshStatus('idle'), 2000)
setRefreshingServers((prev) => ({ ...prev, [serverId]: 'refreshed' }))
setTimeout(() => {
setRefreshingServers((prev) => {
const newState = { ...prev }
delete newState[serverId]
return newState
})
}, 2000)
} catch (error) {
logger.error('Failed to refresh MCP server:', error)
setRefreshStatus('idle')
setRefreshingServers((prev) => {
const newState = { ...prev }
delete newState[serverId]
return newState
})
}
},
[refreshServerMutation, workspaceId]
@@ -432,6 +451,53 @@ export function MCP() {
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
/**
* Gets issues for stored tools that reference a specific server tool.
* Returns issues from all workflows that have stored this tool.
*/
const getStoredToolIssues = useCallback(
(serverId: string, toolName: string): { issue: McpToolIssue; workflowName: string }[] => {
const relevantStoredTools = storedTools.filter(
(st) => st.serverId === serverId && st.toolName === toolName
)
const serverStates = servers.map((s) => ({
id: s.id,
url: s.url,
connectionStatus: s.connectionStatus,
lastError: s.lastError || undefined,
}))
const discoveredTools = mcpToolsData.map((t) => ({
serverId: t.serverId,
name: t.name,
inputSchema: t.inputSchema,
}))
const issues: { issue: McpToolIssue; workflowName: string }[] = []
for (const storedTool of relevantStoredTools) {
const issue = getMcpToolIssue(
{
serverId: storedTool.serverId,
serverUrl: storedTool.serverUrl,
toolName: storedTool.toolName,
schema: storedTool.schema,
},
serverStates,
discoveredTools
)
if (issue) {
issues.push({ issue, workflowName: storedTool.workflowName })
}
}
return issues
},
[storedTools, servers, mcpToolsData]
)
if (selectedServer) {
const { server, tools } = selectedServer
const transportLabel = formatTransportLabel(server.transport || 'http')
@@ -463,6 +529,15 @@ export function MCP() {
</div>
)}
{server.connectionStatus === 'error' && (
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Status</span>
<p className='text-[14px] text-red-500 dark:text-red-400'>
{server.lastError || 'Unable to connect'}
</p>
</div>
)}
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Tools ({tools.length})
@@ -471,21 +546,37 @@ export function MCP() {
<p className='text-[13px] text-[var(--text-muted)]'>No tools available</p>
) : (
<div className='flex flex-col gap-[8px]'>
{tools.map((tool) => (
<div
key={tool.name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
)}
</div>
))}
{tools.map((tool) => {
const issues = getStoredToolIssues(server.id, tool.name)
return (
<div
key={tool.name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
>
<div className='flex items-center justify-between'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{issues.length > 0 && (
<Badge
variant='outline'
style={{
borderColor: 'var(--warning)',
color: 'var(--warning)',
}}
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
)}
</div>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
)}
</div>
)
})}
</div>
)}
</div>
@@ -496,11 +587,11 @@ export function MCP() {
<Button
onClick={() => handleRefreshServer(server.id)}
variant='default'
disabled={refreshStatus !== 'idle'}
disabled={!!refreshingServers[server.id]}
>
{refreshStatus === 'refreshing'
{refreshingServers[server.id] === 'refreshing'
? 'Refreshing...'
: refreshStatus === 'refreshed'
: refreshingServers[server.id] === 'refreshed'
? 'Refreshed'
: 'Refresh Tools'}
</Button>
@@ -672,6 +763,7 @@ export function MCP() {
tools={tools}
isDeleting={deletingServers.has(server.id)}
isLoadingTools={isLoadingTools}
isRefreshing={refreshingServers[server.id] === 'refreshing'}
onRemove={() => handleRemoveServer(server.id, server.name || 'this server')}
onViewDetails={() => handleViewDetails(server.id)}
/>
@@ -46,6 +46,7 @@ import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { useSettingsModalStore } from '@/stores/settings-modal/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
@@ -134,6 +135,8 @@ const allNavigationItems: NavigationItem[] = [
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
const { data: session } = useSession()
const queryClient = useQueryClient()
const { data: organizationsData } = useOrganizations()
@@ -247,6 +250,24 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
// React Query hook automatically loads and syncs settings
useGeneralSettings()
// Apply initial section from store when modal opens
useEffect(() => {
if (open && initialSection) {
setActiveSection(initialSection)
if (mcpServerId) {
setPendingMcpServerId(mcpServerId)
}
clearInitialState()
}
}, [open, initialSection, mcpServerId, clearInitialState])
// Clear pending server ID when section changes away from MCP
useEffect(() => {
if (activeSection !== 'mcp') {
setPendingMcpServerId(null)
}
}, [activeSection])
useEffect(() => {
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
setActiveSection(event.detail.tab)
@@ -436,7 +457,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{isBillingEnabled && activeSection === 'team' && <TeamManagement />}
{activeSection === 'sso' && <SSO />}
{activeSection === 'copilot' && <Copilot />}
{activeSection === 'mcp' && <MCP />}
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{activeSection === 'custom-tools' && <CustomTools />}
</SModalMainBody>
</SModalMain>
@@ -32,6 +32,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useFolderStore } from '@/stores/folders/store'
import { useSearchModalStore } from '@/stores/search-modal/store'
import { useSettingsModalStore } from '@/stores/settings-modal/store'
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
const logger = createLogger('Sidebar')
@@ -88,7 +89,11 @@ export function Sidebar() {
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false)
const {
isOpen: isSettingsModalOpen,
openModal: openSettingsModal,
closeModal: closeSettingsModal,
} = useSettingsModalStore()
/** Listens for external events to open help modal */
useEffect(() => {
@@ -219,7 +224,7 @@ export function Sidebar() {
id: 'settings',
label: 'Settings',
icon: Settings,
onClick: () => setIsSettingsModalOpen(true),
onClick: () => openSettingsModal(),
},
],
[workspaceId]
@@ -654,7 +659,10 @@ export function Sidebar() {
{/* Footer Navigation Modals */}
<HelpModal open={isHelpModalOpen} onOpenChange={setIsHelpModalOpen} />
<SettingsModal open={isSettingsModalOpen} onOpenChange={setIsSettingsModalOpen} />
<SettingsModal
open={isSettingsModalOpen}
onOpenChange={(open) => (open ? openSettingsModal() : closeSettingsModal())}
/>
{/* Hidden file input for workspace import */}
<input
@@ -1,3 +1,6 @@
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -72,6 +75,11 @@ export class BlockExecutor {
try {
resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
if (block.metadata?.id === BlockType.AGENT && resolvedInputs.tools) {
resolvedInputs = await this.filterUnavailableMcpToolsForLog(ctx, resolvedInputs)
}
if (blockLog) {
blockLog.input = resolvedInputs
}
@@ -395,6 +403,60 @@ export class BlockExecutor {
return undefined
}
/**
* Filters out unavailable MCP tools from agent inputs for logging.
* Only includes tools from servers with 'connected' status.
*/
private async filterUnavailableMcpToolsForLog(
ctx: ExecutionContext,
inputs: Record<string, any>
): Promise<Record<string, any>> {
const tools = inputs.tools
if (!Array.isArray(tools) || tools.length === 0) return inputs
const mcpTools = tools.filter((t: any) => t.type === 'mcp')
if (mcpTools.length === 0) return inputs
const serverIds = [
...new Set(mcpTools.map((t: any) => t.params?.serverId).filter(Boolean)),
] as string[]
if (serverIds.length === 0) return inputs
const availableServerIds = new Set<string>()
if (ctx.workspaceId && serverIds.length > 0) {
try {
const servers = await db
.select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus })
.from(mcpServers)
.where(
and(
eq(mcpServers.workspaceId, ctx.workspaceId),
inArray(mcpServers.id, serverIds),
isNull(mcpServers.deletedAt)
)
)
for (const server of servers) {
if (server.connectionStatus === 'connected') {
availableServerIds.add(server.id)
}
}
} catch (error) {
logger.warn('Failed to check MCP server availability for logging:', error)
return inputs
}
}
const filteredTools = tools.filter((tool: any) => {
if (tool.type !== 'mcp') return true
const serverId = tool.params?.serverId
if (!serverId) return false
return availableServerIds.has(serverId)
})
return { ...inputs, tools: filteredTools }
}
private preparePauseResumeSelfReference(
ctx: ExecutionContext,
node: DAGNode,
@@ -1,3 +1,6 @@
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { createMcpToolId } from '@/lib/mcp/utils'
import { getAllBlocks } from '@/blocks'
@@ -35,19 +38,23 @@ export class AgentBlockHandler implements BlockHandler {
block: SerializedBlock,
inputs: AgentInputs
): Promise<BlockOutput | StreamingExecution> {
const responseFormat = this.parseResponseFormat(inputs.responseFormat)
const model = inputs.model || AGENT.DEFAULT_MODEL
// Filter out unavailable MCP tools early so they don't appear in logs/inputs
const filteredTools = await this.filterUnavailableMcpTools(ctx, inputs.tools || [])
const filteredInputs = { ...inputs, tools: filteredTools }
const responseFormat = this.parseResponseFormat(filteredInputs.responseFormat)
const model = filteredInputs.model || AGENT.DEFAULT_MODEL
const providerId = getProviderFromModel(model)
const formattedTools = await this.formatTools(ctx, inputs.tools || [])
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
const streamingConfig = this.getStreamingConfig(ctx, block)
const messages = await this.buildMessages(ctx, inputs, block.id)
const messages = await this.buildMessages(ctx, filteredInputs, block.id)
const providerRequest = this.buildProviderRequest({
ctx,
providerId,
model,
messages,
inputs,
inputs: filteredInputs,
formattedTools,
responseFormat,
streaming: streamingConfig.shouldUseStreaming ?? false,
@@ -58,10 +65,10 @@ export class AgentBlockHandler implements BlockHandler {
providerRequest,
block,
responseFormat,
inputs
filteredInputs
)
await this.persistResponseToMemory(ctx, inputs, result, block.id)
await this.persistResponseToMemory(ctx, filteredInputs, result, block.id)
return result
}
@@ -115,6 +122,53 @@ export class AgentBlockHandler implements BlockHandler {
return undefined
}
private async filterUnavailableMcpTools(
ctx: ExecutionContext,
tools: ToolInput[]
): Promise<ToolInput[]> {
if (!Array.isArray(tools) || tools.length === 0) return tools
const mcpTools = tools.filter((t) => t.type === 'mcp')
if (mcpTools.length === 0) return tools
const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))]
if (serverIds.length === 0) return tools
const availableServerIds = new Set<string>()
if (ctx.workspaceId && serverIds.length > 0) {
try {
const servers = await db
.select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus })
.from(mcpServers)
.where(
and(
eq(mcpServers.workspaceId, ctx.workspaceId),
inArray(mcpServers.id, serverIds),
isNull(mcpServers.deletedAt)
)
)
for (const server of servers) {
if (server.connectionStatus === 'connected') {
availableServerIds.add(server.id)
}
}
} catch (error) {
logger.warn('Failed to check MCP server availability, including all tools:', error)
for (const serverId of serverIds) {
availableServerIds.add(serverId)
}
}
}
return tools.filter((tool) => {
if (tool.type !== 'mcp') return true
const serverId = tool.params?.serverId
if (!serverId) return false
return availableServerIds.has(serverId)
})
}
private async formatTools(ctx: ExecutionContext, inputTools: ToolInput[]): Promise<any[]> {
if (!Array.isArray(inputTools)) return []
@@ -304,6 +358,7 @@ export class AgentBlockHandler implements BlockHandler {
/**
* Process MCP tools using cached schemas from build time.
* Note: Unavailable tools are already filtered by filterUnavailableMcpTools.
*/
private async processMcpToolsBatched(
ctx: ExecutionContext,
@@ -312,7 +367,6 @@ export class AgentBlockHandler implements BlockHandler {
if (mcpTools.length === 0) return []
const results: any[] = []
const toolsWithSchema: ToolInput[] = []
const toolsNeedingDiscovery: ToolInput[] = []
+102 -16
View File
@@ -1,20 +1,17 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createLogger } from '@/lib/logs/console/logger'
import type { McpServerStatusConfig } from '@/lib/mcp/types'
const logger = createLogger('McpQueries')
/**
* Query key factories for MCP-related queries
*/
export type { McpServerStatusConfig }
export const mcpKeys = {
all: ['mcp'] as const,
servers: (workspaceId: string) => [...mcpKeys.all, 'servers', workspaceId] as const,
tools: (workspaceId: string) => [...mcpKeys.all, 'tools', workspaceId] as const,
}
/**
* MCP Server Types
*/
export interface McpServer {
id: string
workspaceId: string
@@ -25,9 +22,11 @@ export interface McpServer {
headers?: Record<string, string>
enabled: boolean
connectionStatus?: 'connected' | 'disconnected' | 'error'
lastError?: string
lastError?: string | null
statusConfig?: McpServerStatusConfig
toolCount?: number
lastToolsRefresh?: string
lastConnected?: string
createdAt: string
updatedAt: string
deletedAt?: string
@@ -86,8 +85,13 @@ export function useMcpServers(workspaceId: string) {
/**
* Fetch MCP tools for a workspace
*/
async function fetchMcpTools(workspaceId: string): Promise<McpTool[]> {
const response = await fetch(`/api/mcp/tools/discover?workspaceId=${workspaceId}`)
async function fetchMcpTools(workspaceId: string, forceRefresh = false): Promise<McpTool[]> {
const params = new URLSearchParams({ workspaceId })
if (forceRefresh) {
params.set('refresh', 'true')
}
const response = await fetch(`/api/mcp/tools/discover?${params.toString()}`)
// Treat 404 as "no tools available" - return empty array
if (response.status === 404) {
@@ -159,14 +163,43 @@ export function useCreateMcpServer() {
return {
...serverData,
id: serverId,
connectionStatus: 'disconnected' as const,
connectionStatus: 'connected' as const,
serverId,
updated: wasUpdated,
}
},
onSuccess: (_data, variables) => {
onSuccess: async (data, variables) => {
const freshTools = await fetchMcpTools(variables.workspaceId, true)
const previousServers = queryClient.getQueryData<McpServer[]>(
mcpKeys.servers(variables.workspaceId)
)
if (previousServers) {
const newServer: McpServer = {
id: data.id,
workspaceId: variables.workspaceId,
name: variables.config.name,
transport: variables.config.transport,
url: variables.config.url,
timeout: variables.config.timeout || 30000,
headers: variables.config.headers,
enabled: variables.config.enabled,
connectionStatus: 'connected',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
const serverExists = previousServers.some((s) => s.id === data.id)
queryClient.setQueryData<McpServer[]>(
mcpKeys.servers(variables.workspaceId),
serverExists
? previousServers.map((s) => (s.id === data.id ? { ...s, ...newServer } : s))
: [...previousServers, newServer]
)
}
queryClient.setQueryData(mcpKeys.tools(variables.workspaceId), freshTools)
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
},
})
}
@@ -213,7 +246,7 @@ export function useDeleteMcpServer() {
interface UpdateMcpServerParams {
workspaceId: string
serverId: string
updates: Partial<McpServerConfig>
updates: Partial<McpServerConfig & { enabled?: boolean }>
}
export function useUpdateMcpServer() {
@@ -221,8 +254,20 @@ export function useUpdateMcpServer() {
return useMutation({
mutationFn: async ({ workspaceId, serverId, updates }: UpdateMcpServerParams) => {
const response = await fetch(`/api/mcp/servers/${serverId}?workspaceId=${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update MCP server')
}
logger.info(`Updated MCP server: ${serverId} in workspace: ${workspaceId}`)
return { serverId, updates }
return data.data?.server
},
onMutate: async ({ workspaceId, serverId, updates }) => {
await queryClient.cancelQueries({ queryKey: mcpKeys.servers(workspaceId) })
@@ -249,6 +294,7 @@ export function useUpdateMcpServer() {
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
},
})
}
@@ -292,9 +338,10 @@ export function useRefreshMcpServer() {
logger.info(`Refreshed MCP server: ${serverId}`)
return data.data
},
onSuccess: (_data, variables) => {
onSuccess: async (_data, variables) => {
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
const freshTools = await fetchMcpTools(variables.workspaceId, true)
queryClient.setQueryData(mcpKeys.tools(variables.workspaceId), freshTools)
},
})
}
@@ -349,3 +396,42 @@ export function useTestMcpServer() {
},
})
}
/**
* Stored MCP tool from workflow state
*/
export interface StoredMcpTool {
workflowId: string
workflowName: string
serverId: string
serverUrl?: string
toolName: string
schema?: Record<string, unknown>
}
/**
* Fetch stored MCP tools from all workflows in the workspace
*/
async function fetchStoredMcpTools(workspaceId: string): Promise<StoredMcpTool[]> {
const response = await fetch(`/api/mcp/tools/stored?workspaceId=${workspaceId}`)
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to fetch stored MCP tools')
}
const data = await response.json()
return data.data?.tools || []
}
/**
* Hook to fetch stored MCP tools from all workflows
*/
export function useStoredMcpTools(workspaceId: string) {
return useQuery({
queryKey: [...mcpKeys.all, workspaceId, 'stored'],
queryFn: () => fetchStoredMcpTools(workspaceId),
enabled: !!workspaceId,
staleTime: 60 * 1000, // 1 minute - workflows don't change frequently
})
}
+16 -9
View File
@@ -34,14 +34,19 @@ export function useMcpServerTest() {
const [isTestingConnection, setIsTestingConnection] = useState(false)
const testConnection = useCallback(
async (config: McpServerTestConfig): Promise<McpServerTestResult> => {
async (
config: McpServerTestConfig,
options?: { silent?: boolean }
): Promise<McpServerTestResult> => {
const { silent = false } = options || {}
if (!config.name || !config.transport || !config.workspaceId) {
const result: McpServerTestResult = {
success: false,
message: 'Missing required configuration',
error: 'Please provide server name, transport method, and workspace ID',
}
setTestResult(result)
if (!silent) setTestResult(result)
return result
}
@@ -51,12 +56,14 @@ export function useMcpServerTest() {
message: 'Missing server URL',
error: 'Please provide a server URL for HTTP/SSE transport',
}
setTestResult(result)
if (!silent) setTestResult(result)
return result
}
setIsTestingConnection(true)
setTestResult(null)
if (!silent) {
setIsTestingConnection(true)
setTestResult(null)
}
try {
const cleanConfig = {
@@ -88,14 +95,14 @@ export function useMcpServerTest() {
error: result.data.error,
warnings: result.data.warnings,
}
setTestResult(testResult)
if (!silent) setTestResult(testResult)
logger.error('MCP server test failed:', result.data.error)
return testResult
}
throw new Error(result.error || 'Connection test failed')
}
setTestResult(result.data || result)
if (!silent) setTestResult(result.data || result)
logger.info(`MCP server test ${result.data?.success ? 'passed' : 'failed'}:`, config.name)
return result.data || result
} catch (error) {
@@ -105,11 +112,11 @@ export function useMcpServerTest() {
message: 'Connection failed',
error: errorMessage,
}
setTestResult(result)
if (!silent) setTestResult(result)
logger.error('MCP server test failed:', errorMessage)
return result
} finally {
setIsTestingConnection(false)
if (!silent) setIsTestingConnection(false)
}
},
[]
+4 -4
View File
@@ -108,7 +108,7 @@ export class McpClient {
this.connectionStatus.lastError = errorMessage
this.isConnected = false
logger.error(`Failed to connect to MCP server ${this.config.name}:`, error)
throw new McpConnectionError(errorMessage, this.config.id)
throw new McpConnectionError(errorMessage, this.config.name)
}
}
@@ -141,7 +141,7 @@ export class McpClient {
*/
async listTools(): Promise<McpTool[]> {
if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.id)
throw new McpConnectionError('Not connected to server', this.config.name)
}
try {
@@ -170,7 +170,7 @@ export class McpClient {
*/
async callTool(toolCall: McpToolCall): Promise<McpToolResult> {
if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.id)
throw new McpConnectionError('Not connected to server', this.config.name)
}
const consentRequest: McpConsentRequest = {
@@ -217,7 +217,7 @@ export class McpClient {
*/
async ping(): Promise<{ _meta?: Record<string, any> }> {
if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.id)
throw new McpConnectionError('Not connected to server', this.config.name)
}
try {
+135 -185
View File
@@ -10,8 +10,14 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { McpClient } from '@/lib/mcp/client'
import {
createMcpCacheAdapter,
getMcpCacheType,
type McpCacheStorageAdapter,
} from '@/lib/mcp/storage'
import type {
McpServerConfig,
McpServerStatusConfig,
McpServerSummary,
McpTool,
McpToolCall,
@@ -22,154 +28,21 @@ import { MCP_CONSTANTS } from '@/lib/mcp/utils'
const logger = createLogger('McpService')
interface ToolCache {
tools: McpTool[]
expiry: Date
lastAccessed: Date
}
interface CacheStats {
totalEntries: number
activeEntries: number
expiredEntries: number
maxCacheSize: number
cacheHitRate: number
memoryUsage: {
approximateBytes: number
entriesEvicted: number
}
}
class McpService {
private toolCache = new Map<string, ToolCache>()
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT // 30 seconds
private readonly maxCacheSize = MCP_CONSTANTS.MAX_CACHE_SIZE // 1000
private cleanupInterval: NodeJS.Timeout | null = null
private cacheHits = 0
private cacheMisses = 0
private entriesEvicted = 0
private cacheAdapter: McpCacheStorageAdapter
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT // 5 minutes
constructor() {
this.startPeriodicCleanup()
}
/**
* Start periodic cleanup of expired cache entries
*/
private startPeriodicCleanup(): void {
this.cleanupInterval = setInterval(
() => {
this.cleanupExpiredEntries()
},
5 * 60 * 1000
)
}
/**
* Stop periodic cleanup
*/
private stopPeriodicCleanup(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
/**
* Cleanup expired cache entries
*/
private cleanupExpiredEntries(): void {
const now = new Date()
const expiredKeys: string[] = []
this.toolCache.forEach((cache, key) => {
if (cache.expiry <= now) {
expiredKeys.push(key)
}
})
expiredKeys.forEach((key) => this.toolCache.delete(key))
if (expiredKeys.length > 0) {
logger.debug(`Cleaned up ${expiredKeys.length} expired cache entries`)
}
}
/**
* Evict least recently used entries when cache exceeds max size
*/
private evictLRUEntries(): void {
if (this.toolCache.size <= this.maxCacheSize) {
return
}
const entries: { key: string; cache: ToolCache }[] = []
this.toolCache.forEach((cache, key) => {
entries.push({ key, cache })
})
entries.sort((a, b) => a.cache.lastAccessed.getTime() - b.cache.lastAccessed.getTime())
const entriesToRemove = this.toolCache.size - this.maxCacheSize + 1
for (let i = 0; i < entriesToRemove && i < entries.length; i++) {
this.toolCache.delete(entries[i].key)
this.entriesEvicted++
}
logger.debug(`Evicted ${entriesToRemove} LRU cache entries to maintain size limit`)
}
/**
* Get cache entry and update last accessed time
*/
private getCacheEntry(key: string): ToolCache | undefined {
const entry = this.toolCache.get(key)
if (entry) {
entry.lastAccessed = new Date()
this.cacheHits++
return entry
}
this.cacheMisses++
return undefined
}
/**
* Set cache entry with LRU eviction
*/
private setCacheEntry(key: string, tools: McpTool[]): void {
const now = new Date()
const cache: ToolCache = {
tools,
expiry: new Date(now.getTime() + this.cacheTimeout),
lastAccessed: now,
}
this.toolCache.set(key, cache)
this.evictLRUEntries()
}
/**
* Calculate approximate memory usage of cache
*/
private calculateMemoryUsage(): number {
let totalBytes = 0
this.toolCache.forEach((cache, key) => {
totalBytes += key.length * 2 // UTF-16 encoding
totalBytes += JSON.stringify(cache.tools).length * 2
totalBytes += 64
})
return totalBytes
this.cacheAdapter = createMcpCacheAdapter()
logger.info(`MCP Service initialized with ${getMcpCacheType()} cache`)
}
/**
* Dispose of the service and cleanup resources
*/
dispose(): void {
this.stopPeriodicCleanup()
this.toolCache.clear()
logger.info('MCP Service disposed and cleanup stopped')
this.cacheAdapter.dispose()
logger.info('MCP Service disposed')
}
/**
@@ -385,6 +258,81 @@ class McpService {
)
}
/**
* Update server connection status after discovery attempt
*/
private async updateServerStatus(
serverId: string,
workspaceId: string,
success: boolean,
error?: string,
toolCount?: number
): Promise<void> {
try {
const [currentServer] = await db
.select({ statusConfig: mcpServers.statusConfig })
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
const currentConfig: McpServerStatusConfig =
(currentServer?.statusConfig as McpServerStatusConfig | null) ?? {
consecutiveFailures: 0,
lastSuccessfulDiscovery: null,
}
const now = new Date()
if (success) {
await db
.update(mcpServers)
.set({
connectionStatus: 'connected',
lastConnected: now,
lastError: null,
toolCount: toolCount ?? 0,
lastToolsRefresh: now,
statusConfig: {
consecutiveFailures: 0,
lastSuccessfulDiscovery: now.toISOString(),
},
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
} else {
const newFailures = currentConfig.consecutiveFailures + 1
const isErrorState = newFailures >= MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES
await db
.update(mcpServers)
.set({
connectionStatus: isErrorState ? 'error' : 'disconnected',
lastError: error || 'Unknown error',
statusConfig: {
consecutiveFailures: newFailures,
lastSuccessfulDiscovery: currentConfig.lastSuccessfulDiscovery,
},
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
if (isErrorState) {
logger.warn(
`Server ${serverId} marked as error after ${newFailures} consecutive failures`
)
}
}
} catch (err) {
logger.error(`Failed to update server status for ${serverId}:`, err)
}
}
/**
* Discover tools from all workspace servers
*/
@@ -399,10 +347,14 @@ class McpService {
try {
if (!forceRefresh) {
const cached = this.getCacheEntry(cacheKey)
if (cached && cached.expiry > new Date()) {
logger.debug(`[${requestId}] Using cached tools for user ${userId}`)
return cached.tools
try {
const cached = await this.cacheAdapter.get(cacheKey)
if (cached) {
logger.debug(`[${requestId}] Using cached tools for user ${userId}`)
return cached.tools
}
} catch (error) {
logger.warn(`[${requestId}] Cache read failed, proceeding with discovery:`, error)
}
}
@@ -425,7 +377,7 @@ class McpService {
logger.debug(
`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`
)
return tools
return { serverId: config.id, tools }
} finally {
await client.disconnect()
}
@@ -433,20 +385,44 @@ class McpService {
)
let failedCount = 0
const statusUpdates: Promise<void>[] = []
results.forEach((result, index) => {
const server = servers[index]
if (result.status === 'fulfilled') {
allTools.push(...result.value)
allTools.push(...result.value.tools)
statusUpdates.push(
this.updateServerStatus(
server.id!,
workspaceId,
true,
undefined,
result.value.tools.length
)
)
} else {
failedCount++
const errorMessage =
result.reason instanceof Error ? result.reason.message : 'Unknown error'
logger.warn(
`[${requestId}] Failed to discover tools from server ${servers[index].name}:`,
`[${requestId}] Failed to discover tools from server ${server.name}:`,
result.reason
)
statusUpdates.push(this.updateServerStatus(server.id!, workspaceId, false, errorMessage))
}
})
// Update server statuses in parallel (don't block on this)
Promise.allSettled(statusUpdates).catch((err) => {
logger.error(`[${requestId}] Error updating server statuses:`, err)
})
if (failedCount === 0) {
this.setCacheEntry(cacheKey, allTools)
try {
await this.cacheAdapter.set(cacheKey, allTools, this.cacheTimeout)
} catch (error) {
logger.warn(`[${requestId}] Cache write failed:`, error)
}
} else {
logger.warn(
`[${requestId}] Skipping cache due to ${failedCount} failed server(s) - will retry on next request`
@@ -565,44 +541,18 @@ class McpService {
/**
* Clear tool cache for a workspace or all workspaces
*/
clearCache(workspaceId?: string): void {
if (workspaceId) {
const workspaceCacheKey = `workspace:${workspaceId}`
this.toolCache.delete(workspaceCacheKey)
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId}`)
} else {
this.toolCache.clear()
this.cacheHits = 0
this.cacheMisses = 0
this.entriesEvicted = 0
logger.debug('Cleared all MCP tool cache and reset statistics')
}
}
/**
* Get comprehensive cache statistics
*/
getCacheStats(): CacheStats {
const entries: { key: string; cache: ToolCache }[] = []
this.toolCache.forEach((cache, key) => {
entries.push({ key, cache })
})
const now = new Date()
const activeEntries = entries.filter(({ cache }) => cache.expiry > now)
const totalRequests = this.cacheHits + this.cacheMisses
const hitRate = totalRequests > 0 ? this.cacheHits / totalRequests : 0
return {
totalEntries: entries.length,
activeEntries: activeEntries.length,
expiredEntries: entries.length - activeEntries.length,
maxCacheSize: this.maxCacheSize,
cacheHitRate: Math.round(hitRate * 100) / 100,
memoryUsage: {
approximateBytes: this.calculateMemoryUsage(),
entriesEvicted: this.entriesEvicted,
},
async clearCache(workspaceId?: string): Promise<void> {
try {
if (workspaceId) {
const workspaceCacheKey = `workspace:${workspaceId}`
await this.cacheAdapter.delete(workspaceCacheKey)
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId}`)
} else {
await this.cacheAdapter.clear()
logger.debug('Cleared all MCP tool cache')
}
} catch (error) {
logger.warn('Failed to clear cache:', error)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { McpTool } from '@/lib/mcp/types'
export interface McpCacheEntry {
tools: McpTool[]
expiry: number // Unix timestamp ms
}
export interface McpCacheStorageAdapter {
get(key: string): Promise<McpCacheEntry | null>
set(key: string, tools: McpTool[], ttlMs: number): Promise<void>
delete(key: string): Promise<void>
clear(): Promise<void>
dispose(): void
}
+53
View File
@@ -0,0 +1,53 @@
import { getRedisClient } from '@/lib/core/config/redis'
import { createLogger } from '@/lib/logs/console/logger'
import type { McpCacheStorageAdapter } from './adapter'
import { MemoryMcpCache } from './memory-cache'
import { RedisMcpCache } from './redis-cache'
const logger = createLogger('McpCacheFactory')
let cachedAdapter: McpCacheStorageAdapter | null = null
/**
* Create MCP cache storage adapter.
* Uses Redis if available, falls back to in-memory cache.
*
* Unlike rate-limiting (which fails if Redis is configured but unavailable),
* MCP caching gracefully falls back to memory since it's an optimization.
*/
export function createMcpCacheAdapter(): McpCacheStorageAdapter {
if (cachedAdapter) {
return cachedAdapter
}
const redis = getRedisClient()
if (redis) {
logger.info('MCP cache: Using Redis')
cachedAdapter = new RedisMcpCache(redis)
} else {
logger.info('MCP cache: Using in-memory (Redis not configured)')
cachedAdapter = new MemoryMcpCache()
}
return cachedAdapter
}
/**
* Get the current adapter type for logging/debugging
*/
export function getMcpCacheType(): 'redis' | 'memory' {
const redis = getRedisClient()
return redis ? 'redis' : 'memory'
}
/**
* Reset the cached adapter.
* Only use for testing purposes.
*/
export function resetMcpCacheAdapter(): void {
if (cachedAdapter) {
cachedAdapter.dispose()
cachedAdapter = null
}
}
+4
View File
@@ -0,0 +1,4 @@
export type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
export { createMcpCacheAdapter, getMcpCacheType, resetMcpCacheAdapter } from './factory'
export { MemoryMcpCache } from './memory-cache'
export { RedisMcpCache } from './redis-cache'
+103
View File
@@ -0,0 +1,103 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { McpTool } from '@/lib/mcp/types'
import { MCP_CONSTANTS } from '@/lib/mcp/utils'
import type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
const logger = createLogger('McpMemoryCache')
export class MemoryMcpCache implements McpCacheStorageAdapter {
private cache = new Map<string, McpCacheEntry>()
private readonly maxCacheSize = MCP_CONSTANTS.MAX_CACHE_SIZE
private cleanupInterval: NodeJS.Timeout | null = null
constructor() {
this.startPeriodicCleanup()
}
private startPeriodicCleanup(): void {
this.cleanupInterval = setInterval(
() => {
this.cleanupExpiredEntries()
},
5 * 60 * 1000 // 5 minutes
)
// Don't keep Node process alive just for cache cleanup
this.cleanupInterval.unref()
}
private cleanupExpiredEntries(): void {
const now = Date.now()
const expiredKeys: string[] = []
this.cache.forEach((entry, key) => {
if (entry.expiry <= now) {
expiredKeys.push(key)
}
})
expiredKeys.forEach((key) => this.cache.delete(key))
if (expiredKeys.length > 0) {
logger.debug(`Cleaned up ${expiredKeys.length} expired cache entries`)
}
}
private evictIfNeeded(): void {
if (this.cache.size <= this.maxCacheSize) {
return
}
// Evict oldest entries (by insertion order - Map maintains order)
const entriesToRemove = this.cache.size - this.maxCacheSize
const keys = Array.from(this.cache.keys()).slice(0, entriesToRemove)
keys.forEach((key) => this.cache.delete(key))
logger.debug(`Evicted ${entriesToRemove} cache entries`)
}
async get(key: string): Promise<McpCacheEntry | null> {
const entry = this.cache.get(key)
const now = Date.now()
if (!entry || entry.expiry <= now) {
if (entry) {
this.cache.delete(key)
}
return null
}
// Return copy to prevent caller from mutating cache
return {
tools: entry.tools,
expiry: entry.expiry,
}
}
async set(key: string, tools: McpTool[], ttlMs: number): Promise<void> {
const now = Date.now()
const entry: McpCacheEntry = {
tools,
expiry: now + ttlMs,
}
this.cache.set(key, entry)
this.evictIfNeeded()
}
async delete(key: string): Promise<void> {
this.cache.delete(key)
}
async clear(): Promise<void> {
this.cache.clear()
}
dispose(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
this.cache.clear()
logger.info('Memory cache disposed')
}
}
+96
View File
@@ -0,0 +1,96 @@
import type Redis from 'ioredis'
import { createLogger } from '@/lib/logs/console/logger'
import type { McpTool } from '@/lib/mcp/types'
import type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
const logger = createLogger('McpRedisCache')
const REDIS_KEY_PREFIX = 'mcp:tools:'
export class RedisMcpCache implements McpCacheStorageAdapter {
constructor(private redis: Redis) {}
private getKey(key: string): string {
return `${REDIS_KEY_PREFIX}${key}`
}
async get(key: string): Promise<McpCacheEntry | null> {
try {
const redisKey = this.getKey(key)
const data = await this.redis.get(redisKey)
if (!data) {
return null
}
try {
return JSON.parse(data) as McpCacheEntry
} catch {
// Corrupted data - delete and treat as miss
logger.warn('Corrupted cache entry, deleting:', redisKey)
await this.redis.del(redisKey)
return null
}
} catch (error) {
logger.error('Redis cache get error:', error)
throw error
}
}
async set(key: string, tools: McpTool[], ttlMs: number): Promise<void> {
try {
const now = Date.now()
const entry: McpCacheEntry = {
tools,
expiry: now + ttlMs,
}
await this.redis.set(this.getKey(key), JSON.stringify(entry), 'PX', ttlMs)
} catch (error) {
logger.error('Redis cache set error:', error)
throw error
}
}
async delete(key: string): Promise<void> {
try {
await this.redis.del(this.getKey(key))
} catch (error) {
logger.error('Redis cache delete error:', error)
throw error
}
}
async clear(): Promise<void> {
try {
let cursor = '0'
let deletedCount = 0
do {
const [nextCursor, keys] = await this.redis.scan(
cursor,
'MATCH',
`${REDIS_KEY_PREFIX}*`,
'COUNT',
100
)
cursor = nextCursor
if (keys.length > 0) {
await this.redis.del(...keys)
deletedCount += keys.length
}
} while (cursor !== '0')
logger.debug(`Cleared ${deletedCount} MCP cache entries from Redis`)
} catch (error) {
logger.error('Redis cache clear error:', error)
throw error
}
}
dispose(): void {
// Redis client is managed externally, nothing to dispose
logger.info('Redis cache adapter disposed')
}
}
+129
View File
@@ -0,0 +1,129 @@
/**
* MCP Tool Validation
*
* Shared logic for detecting issues with MCP tools across the platform.
* Used by both tool-input.tsx (workflow context) and MCP modal (workspace context).
*/
import isEqual from 'lodash/isEqual'
import omit from 'lodash/omit'
export type McpToolIssueType =
| 'server_not_found'
| 'server_error'
| 'tool_not_found'
| 'schema_changed'
| 'url_changed'
export interface McpToolIssue {
type: McpToolIssueType
message: string
}
export interface StoredMcpTool {
serverId: string
serverUrl?: string
toolName: string
schema?: Record<string, unknown>
}
export interface ServerState {
id: string
url?: string
connectionStatus?: 'connected' | 'disconnected' | 'error'
lastError?: string
}
export interface DiscoveredTool {
serverId: string
name: string
inputSchema?: Record<string, unknown>
}
/**
* Compares two schemas to detect changes.
* Uses lodash isEqual for deep, key-order-independent comparison.
* Ignores description field which may be backfilled.
*/
export function hasSchemaChanged(
storedSchema: Record<string, unknown> | undefined,
serverSchema: Record<string, unknown> | undefined
): boolean {
if (!storedSchema || !serverSchema) return false
const storedWithoutDesc = omit(storedSchema, 'description')
const serverWithoutDesc = omit(serverSchema, 'description')
return !isEqual(storedWithoutDesc, serverWithoutDesc)
}
/**
* Detects issues with a stored MCP tool by comparing against current server/tool state.
*/
export function getMcpToolIssue(
storedTool: StoredMcpTool,
servers: ServerState[],
discoveredTools: DiscoveredTool[]
): McpToolIssue | null {
const { serverId, serverUrl, toolName, schema } = storedTool
// Check server exists
const server = servers.find((s) => s.id === serverId)
if (!server) {
return { type: 'server_not_found', message: 'Server not found' }
}
// Check server connection status
if (server.connectionStatus === 'error') {
return { type: 'server_error', message: server.lastError || 'Server connection error' }
}
if (server.connectionStatus !== 'connected') {
return { type: 'server_error', message: 'Server not connected' }
}
// Check server URL changed (if we have stored URL)
if (serverUrl && server.url && serverUrl !== server.url) {
return { type: 'url_changed', message: 'Server URL changed - tools may be different' }
}
// Check tool exists on server
const serverTool = discoveredTools.find((t) => t.serverId === serverId && t.name === toolName)
if (!serverTool) {
return { type: 'tool_not_found', message: 'Tool not found on server' }
}
// Check schema changed
if (schema && serverTool.inputSchema) {
if (hasSchemaChanged(schema, serverTool.inputSchema)) {
return { type: 'schema_changed', message: 'Tool schema changed' }
}
}
return null
}
/**
* Returns a user-friendly label for the issue badge
*/
export function getIssueBadgeLabel(issue: McpToolIssue): string {
switch (issue.type) {
case 'schema_changed':
return 'stale'
case 'url_changed':
return 'stale'
default:
return 'unavailable'
}
}
/**
* Checks if an issue means the tool cannot be used (vs just being stale)
*/
export function isToolUnavailable(issue: McpToolIssue | null): boolean {
if (!issue) return false
return (
issue.type === 'server_not_found' ||
issue.type === 'server_error' ||
issue.type === 'tool_not_found'
)
}
+8 -2
View File
@@ -6,6 +6,11 @@
// Modern MCP uses Streamable HTTP which handles both HTTP POST and SSE responses
export type McpTransport = 'streamable-http'
export interface McpServerStatusConfig {
consecutiveFailures: number
lastSuccessfulDiscovery: string | null
}
export interface McpServerConfig {
id: string
name: string
@@ -20,6 +25,7 @@ export interface McpServerConfig {
timeout?: number
retries?: number
enabled?: boolean
statusConfig?: McpServerStatusConfig
createdAt?: string
updatedAt?: string
}
@@ -113,8 +119,8 @@ export class McpError extends Error {
}
export class McpConnectionError extends McpError {
constructor(message: string, serverId: string) {
super(`MCP Connection Error for server ${serverId}: ${message}`)
constructor(message: string, serverName: string) {
super(`Failed to connect to "${serverName}": ${message}`)
this.name = 'McpConnectionError'
}
}
+2 -1
View File
@@ -6,10 +6,11 @@ import type { McpApiResponse } from '@/lib/mcp/types'
*/
export const MCP_CONSTANTS = {
EXECUTION_TIMEOUT: 60000,
CACHE_TIMEOUT: 30 * 1000,
CACHE_TIMEOUT: 5 * 60 * 1000, // 5 minutes
DEFAULT_RETRIES: 3,
DEFAULT_CONNECTION_TIMEOUT: 30000,
MAX_CACHE_SIZE: 1000,
MAX_CONSECUTIVE_FAILURES: 3,
} as const
/**
+3 -1
View File
@@ -1001,7 +1001,7 @@ export function supportsToolUsageControl(provider: string): boolean {
* Prepare tool execution parameters, separating tool parameters from system parameters
*/
export function prepareToolExecution(
tool: { params?: Record<string, any> },
tool: { params?: Record<string, any>; parameters?: Record<string, any> },
llmArgs: Record<string, any>,
request: {
workflowId?: string
@@ -1051,6 +1051,8 @@ export function prepareToolExecution(
...(request.workflowVariables ? { workflowVariables: request.workflowVariables } : {}),
...(request.blockData ? { blockData: request.blockData } : {}),
...(request.blockNameMapping ? { blockNameMapping: request.blockNameMapping } : {}),
// Pass tool schema for MCP tools to skip discovery
...(tool.parameters ? { _toolSchema: tool.parameters } : {}),
}
return { toolParams, executionParams }
+51
View File
@@ -0,0 +1,51 @@
'use client'
import { create } from 'zustand'
type SettingsSection =
| 'general'
| 'environment'
| 'template-profile'
| 'integrations'
| 'apikeys'
| 'files'
| 'subscription'
| 'team'
| 'sso'
| 'copilot'
| 'mcp'
| 'custom-tools'
interface SettingsModalState {
isOpen: boolean
initialSection: SettingsSection | null
mcpServerId: string | null
openModal: (options?: { section?: SettingsSection; mcpServerId?: string }) => void
closeModal: () => void
clearInitialState: () => void
}
export const useSettingsModalStore = create<SettingsModalState>((set) => ({
isOpen: false,
initialSection: null,
mcpServerId: null,
openModal: (options) =>
set({
isOpen: true,
initialSection: options?.section || null,
mcpServerId: options?.mcpServerId || null,
}),
closeModal: () =>
set({
isOpen: false,
}),
clearInitialState: () =>
set({
initialSection: null,
mcpServerId: null,
}),
}))
+11 -1
View File
@@ -107,6 +107,7 @@ const MCP_SYSTEM_PARAMETERS = new Set([
'workflowVariables',
'blockData',
'blockNameMapping',
'_toolSchema',
])
/**
@@ -979,7 +980,10 @@ async function executeMcpTool(
}
}
const requestBody = {
// Get tool schema if provided (from agent block's cached schema)
const toolSchema = params._toolSchema
const requestBody: Record<string, any> = {
serverId,
toolName,
arguments: toolArguments,
@@ -987,6 +991,11 @@ async function executeMcpTool(
workspaceId, // Pass workspace context for scoping
}
// Include schema to skip discovery on execution
if (toolSchema) {
requestBody.toolSchema = toolSchema
}
const body = JSON.stringify(requestBody)
// Check request body size before sending
@@ -995,6 +1004,7 @@ async function executeMcpTool(
logger.info(`[${actualRequestId}] Making MCP tool request to ${toolName} on ${serverId}`, {
hasWorkspaceId: !!workspaceId,
hasWorkflowId: !!workflowId,
hasToolSchema: !!toolSchema,
})
const response = await fetch(`${baseUrl}/api/mcp/tools/execute`, {
@@ -0,0 +1 @@
ALTER TABLE "mcp_servers" ADD COLUMN "status_config" jsonb DEFAULT '{}';
File diff suppressed because it is too large Load Diff
@@ -855,6 +855,13 @@
"when": 1765587157593,
"tag": "0122_pale_absorbing_man",
"breakpoints": true
},
{
"idx": 123,
"version": "7",
"when": 1765932898404,
"tag": "0123_windy_lockheed",
"breakpoints": true
}
]
}
+2
View File
@@ -1547,6 +1547,8 @@ export const mcpServers = pgTable(
connectionStatus: text('connection_status').default('disconnected'),
lastError: text('last_error'),
statusConfig: jsonb('status_config').default('{}'),
toolCount: integer('tool_count').default(0),
lastToolsRefresh: timestamp('last_tools_refresh'),
totalRequests: integer('total_requests').default(0),