feat(mcp): surface better errors for MCP connection failures (#1796)

This commit is contained in:
Waleed
2025-11-03 14:28:25 -08:00
committed by GitHub
parent 2eea3caccd
commit 3af7d136c6
4 changed files with 430 additions and 623 deletions

View File

@@ -0,0 +1,304 @@
'use client'
import { Plus, X } from 'lucide-react'
import { Button, Input, Label } from '@/components/ui'
import { EnvVarDropdown } from '@/components/ui/env-var-dropdown'
import { formatDisplayText } from '@/components/ui/formatted-text'
import type { McpServerFormData, McpServerTestResult } from '../types'
interface AddServerFormProps {
formData: McpServerFormData
testResult: McpServerTestResult | null
isTestingConnection: boolean
isAddingServer: boolean
serversLoading: boolean
showEnvVars: boolean
activeInputField: 'url' | 'header-key' | 'header-value' | null
activeHeaderIndex: number | null
envSearchTerm: string
cursorPosition: number
urlScrollLeft: number
headerScrollLeft: Record<string, number>
workspaceId: string
urlInputRef: React.RefObject<HTMLInputElement | null>
onNameChange: (value: string) => void
onInputChange: (
field: 'url' | 'header-key' | 'header-value',
value: string,
index?: number
) => void
onUrlScroll: (scrollLeft: number) => void
onHeaderScroll: (key: string, scrollLeft: number) => void
onEnvVarSelect: (value: string) => void
onEnvVarClose: () => void
onAddHeader: () => void
onRemoveHeader: (key: string) => void
onTestConnection: () => void
onCancel: () => void
onAddServer: () => void
onClearTestResult: () => void
}
export function AddServerForm({
formData,
testResult,
isTestingConnection,
isAddingServer,
serversLoading,
showEnvVars,
activeInputField,
activeHeaderIndex,
envSearchTerm,
cursorPosition,
urlScrollLeft,
headerScrollLeft,
workspaceId,
urlInputRef,
onNameChange,
onInputChange,
onUrlScroll,
onHeaderScroll,
onEnvVarSelect,
onEnvVarClose,
onAddHeader,
onRemoveHeader,
onTestConnection,
onCancel,
onAddServer,
onClearTestResult,
}: AddServerFormProps) {
return (
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
<div className='space-y-1.5'>
<div className='flex items-center justify-between gap-3'>
<Label className='w-[100px] shrink-0 font-normal text-sm'>Server Name</Label>
<div className='flex-1'>
<Input
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => {
if (testResult) onClearTestResult()
onNameChange(e.target.value)
}}
className='h-9'
/>
</div>
</div>
<div className='flex items-center justify-between gap-3'>
<Label className='w-[100px] shrink-0 font-normal text-sm'>Server URL</Label>
<div className='relative flex-1'>
<Input
ref={urlInputRef}
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
value={formData.url}
onChange={(e) => onInputChange('url', e.target.value)}
onScroll={(e) => onUrlScroll(e.currentTarget.scrollLeft)}
onInput={(e) => onUrlScroll(e.currentTarget.scrollLeft)}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
{/* Overlay for styled text display */}
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{ transform: `translateX(-${urlScrollLeft}px)` }}
>
{formatDisplayText(formData.url || '')}
</div>
</div>
{/* Environment Variables Dropdown */}
{showEnvVars && activeInputField === 'url' && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={onEnvVarSelect}
searchTerm={envSearchTerm}
inputValue={formData.url || ''}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={onEnvVarClose}
className='w-full'
maxHeight='200px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 99999,
}}
/>
)}
</div>
</div>
{Object.entries(formData.headers || {}).map(([key, value], index) => (
<div key={index} className='relative flex items-center justify-between gap-3'>
<Label className='w-[100px] shrink-0 font-normal text-sm'>Header</Label>
<div className='relative flex flex-1 gap-2'>
{/* Header Key Input */}
<div className='relative flex-1'>
<Input
placeholder='Name'
value={key}
onChange={(e) => onInputChange('header-key', e.target.value, index)}
onScroll={(e) => onHeaderScroll(`key-${index}`, e.currentTarget.scrollLeft)}
onInput={(e) => onHeaderScroll(`key-${index}`, e.currentTarget.scrollLeft)}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{
transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`,
}}
>
{formatDisplayText(key || '')}
</div>
</div>
</div>
{/* Header Value Input */}
<div className='relative flex-1'>
<Input
placeholder='Value'
value={value}
onChange={(e) => onInputChange('header-value', e.target.value, index)}
onScroll={(e) => onHeaderScroll(`value-${index}`, e.currentTarget.scrollLeft)}
onInput={(e) => onHeaderScroll(`value-${index}`, e.currentTarget.scrollLeft)}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{
transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`,
}}
>
{formatDisplayText(value || '')}
</div>
</div>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => onRemoveHeader(key)}
className='h-9 w-9 p-0 text-muted-foreground hover:text-foreground'
>
<X className='h-3 w-3' />
</Button>
{/* Environment Variables Dropdown for Header Key */}
{showEnvVars && activeInputField === 'header-key' && activeHeaderIndex === index && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={onEnvVarSelect}
searchTerm={envSearchTerm}
inputValue={key}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={onEnvVarClose}
className='w-full'
maxHeight='200px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 99999,
}}
/>
)}
{/* Environment Variables Dropdown for Header Value */}
{showEnvVars &&
activeInputField === 'header-value' &&
activeHeaderIndex === index && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={onEnvVarSelect}
searchTerm={envSearchTerm}
inputValue={value}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={onEnvVarClose}
className='w-full'
maxHeight='200px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 99999,
}}
/>
)}
</div>
</div>
))}
<div className='flex items-center justify-between gap-3'>
<div className='w-[100px] shrink-0' />
<div className='flex-1'>
<Button
type='button'
variant='outline'
size='sm'
onClick={onAddHeader}
className='h-9 text-muted-foreground hover:text-foreground'
>
<Plus className='mr-2 h-3 w-3' />
Add Header
</Button>
</div>
</div>
<div className='border-border border-t pt-2'>
<div className='space-y-1.5'>
{/* Error message above buttons */}
{testResult && !testResult.success && (
<p className='text-red-600 text-sm'>{testResult.error || testResult.message}</p>
)}
{/* Buttons row */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={onTestConnection}
disabled={isTestingConnection || !formData.name.trim() || !formData.url?.trim()}
className='text-muted-foreground hover:text-foreground'
>
{isTestingConnection ? 'Testing...' : 'Test Connection'}
</Button>
{testResult?.success && <span className='text-green-600 text-xs'> Connected</span>}
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={onCancel}
className='text-muted-foreground hover:text-foreground'
>
Cancel
</Button>
<Button
size='sm'
onClick={onAddServer}
disabled={
serversLoading ||
isAddingServer ||
!formData.name.trim() ||
!formData.url?.trim()
}
className='h-9 rounded-[8px]'
>
{serversLoading || isAddingServer ? 'Adding...' : 'Add Server'}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,27 +1,19 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Plus, Search, X } from 'lucide-react'
import { AlertCircle, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Alert, AlertDescription, Button, Input, Label, Skeleton } from '@/components/ui'
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { Alert, AlertDescription, Button, Input, Skeleton } from '@/components/ui'
import { checkEnvVarTrigger } from '@/components/ui/env-var-dropdown'
import { createLogger } from '@/lib/logs/console/logger'
import type { McpTransport } from '@/lib/mcp/types'
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
import { useMcpTools } from '@/hooks/use-mcp-tools'
import { useMcpServersStore } from '@/stores/mcp-servers/store'
import { AddServerForm } from './components/add-server-form'
import type { McpServerFormData } from './types'
const logger = createLogger('McpSettings')
interface McpServerFormData {
name: string
transport: McpTransport
url?: string
timeout?: number
headers?: Record<string, string>
}
export function MCP() {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -46,7 +38,6 @@ export function MCP() {
headers: {}, // Start with no headers
})
// Environment variable dropdown state
const [showEnvVars, setShowEnvVars] = useState(false)
const [envSearchTerm, setEnvSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
@@ -56,17 +47,13 @@ export function MCP() {
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
const urlInputRef = useRef<HTMLInputElement>(null)
// MCP server testing
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
// Loading state for adding server
const [isAddingServer, setIsAddingServer] = useState(false)
// State for tracking input scroll position
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
// Handle environment variable selection
const handleEnvVarSelect = useCallback(
(newValue: string) => {
if (activeInputField === 'url') {
@@ -93,7 +80,6 @@ export function MCP() {
[activeInputField, activeHeaderIndex, formData.headers]
)
// Handle input change with env var detection
const handleInputChange = useCallback(
(field: 'url' | 'header-key' | 'header-value', value: string, headerIndex?: number) => {
const input = document.activeElement as HTMLInputElement
@@ -101,12 +87,10 @@ export function MCP() {
setCursorPosition(pos)
// Clear test result when any field changes
if (testResult) {
clearTestResult()
}
// Check if we should show the environment variables dropdown
const envVarTrigger = checkEnvVarTrigger(value, pos)
setShowEnvVars(envVarTrigger.show)
setEnvSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
@@ -119,7 +103,6 @@ export function MCP() {
setActiveHeaderIndex(null)
}
// Update form data
if (field === 'url') {
setFormData((prev) => ({ ...prev, url: value }))
} else if (field === 'header-key' && headerIndex !== undefined) {
@@ -159,7 +142,6 @@ export function MCP() {
setIsAddingServer(true)
try {
// If no test has been done, test first
if (!testResult) {
const result = await testConnection({
name: formData.name,
@@ -170,13 +152,11 @@ export function MCP() {
workspaceId,
})
// If test fails, don't proceed
if (!result.success) {
return
}
}
// If we have a failed test result, don't proceed
if (testResult && !testResult.success) {
return
}
@@ -192,13 +172,12 @@ export function MCP() {
logger.info(`Added MCP server: ${formData.name}`)
// Reset form and hide form immediately after server creation
setFormData({
name: '',
transport: 'streamable-http',
url: '',
timeout: 30000,
headers: {}, // Reset with no headers
headers: {},
})
setShowAddForm(false)
setShowEnvVars(false)
@@ -206,7 +185,6 @@ export function MCP() {
setActiveHeaderIndex(null)
clearTestResult()
// Refresh tools in the background without waiting
refreshTools(true) // Force refresh after adding server
} catch (error) {
logger.error('Failed to add MCP server:', error)
@@ -225,24 +203,21 @@ export function MCP() {
const handleRemoveServer = useCallback(
async (serverId: string) => {
// Add server to deleting set
setDeletingServers((prev) => new Set(prev).add(serverId))
try {
await deleteServer(workspaceId, serverId)
await refreshTools(true) // Force refresh after removing server
await refreshTools(true)
logger.info(`Removed MCP server: ${serverId}`)
} catch (error) {
logger.error('Failed to remove MCP server:', error)
// Remove from deleting set on error so user can try again
setDeletingServers((prev) => {
const newSet = new Set(prev)
newSet.delete(serverId)
return newSet
})
} finally {
// Remove from deleting set after successful deletion
setDeletingServers((prev) => {
const newSet = new Set(prev)
newSet.delete(serverId)
@@ -253,16 +228,15 @@ export function MCP() {
[deleteServer, refreshTools, workspaceId]
)
// Load data on mount only
useEffect(() => {
fetchServers(workspaceId)
refreshTools() // Don't force refresh on mount
refreshTools()
}, [fetchServers, refreshTools, workspaceId])
const toolsByServer = (mcpTools || []).reduce(
(acc, tool) => {
if (!tool || !tool.serverId) {
return acc // Skip invalid tools
return acc
}
if (!acc[tool.serverId]) {
acc[tool.serverId] = []
@@ -273,7 +247,6 @@ export function MCP() {
{} as Record<string, typeof mcpTools>
)
// Filter servers based on search term
const filteredServers = (servers || []).filter((server) =>
server.name?.toLowerCase().includes(searchTerm.toLowerCase())
)
@@ -281,12 +254,12 @@ export function MCP() {
return (
<div className='relative flex h-full flex-col'>
{/* Fixed Header with Search */}
<div className='px-6 pt-4 pb-2'>
<div className='px-6 pt-2 pb-2'>
{/* Search Input */}
{serversLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' />
<Skeleton className='h-9 w-56 rounded-[8px]' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<div className='flex h-9 w-56 items-center gap-2 rounded-[8px] border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search servers...'
@@ -308,303 +281,56 @@ export function MCP() {
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-4 py-2'>
<div className='h-full space-y-2 py-2'>
{/* Server List */}
{serversLoading ? (
<div className='space-y-4'>
<div className='space-y-2'>
<McpServerSkeleton />
<McpServerSkeleton />
<McpServerSkeleton />
</div>
) : !servers || servers.length === 0 ? (
showAddForm ? (
<div className='rounded-[8px] border bg-background p-4 shadow-xs'>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label className='font-normal'>Server Name</Label>
</div>
<div className='w-[380px]'>
<Input
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => {
if (testResult) clearTestResult()
setFormData((prev) => ({ ...prev, name: e.target.value }))
}}
className='h-9'
/>
</div>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label className='font-normal'>Server URL</Label>
</div>
<div className='relative w-[380px]'>
<Input
ref={urlInputRef}
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
value={formData.url}
onChange={(e) => handleInputChange('url', e.target.value)}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setUrlScrollLeft(scrollLeft)
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setUrlScrollLeft(scrollLeft)
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
{/* Overlay for styled text display */}
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{ transform: `translateX(-${urlScrollLeft}px)` }}
>
{formatDisplayText(formData.url || '')}
</div>
</div>
{/* Environment Variables Dropdown */}
{showEnvVars && activeInputField === 'url' && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={envSearchTerm}
inputValue={formData.url || ''}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
}}
className='w-[380px]'
maxHeight='200px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 99999,
}}
/>
)}
</div>
</div>
{Object.entries(formData.headers || {}).map(([key, value], index) => (
<div key={index} className='relative flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label className='font-normal'>Header</Label>
</div>
<div className='relative flex w-[380px] gap-2'>
{/* Header Key Input */}
<div className='relative flex-1'>
<Input
placeholder='Name'
value={key}
onChange={(e) => handleInputChange('header-key', e.target.value, index)}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({
...prev,
[`key-${index}`]: scrollLeft,
}))
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({
...prev,
[`key-${index}`]: scrollLeft,
}))
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{
transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`,
}}
>
{formatDisplayText(key || '')}
</div>
</div>
</div>
{/* Header Value Input */}
<div className='relative flex-1'>
<Input
placeholder='Value'
value={value}
onChange={(e) =>
handleInputChange('header-value', e.target.value, index)
}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({
...prev,
[`value-${index}`]: scrollLeft,
}))
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({
...prev,
[`value-${index}`]: scrollLeft,
}))
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{
transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`,
}}
>
{formatDisplayText(value || '')}
</div>
</div>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => {
const newHeaders = { ...formData.headers }
delete newHeaders[key]
setFormData((prev) => ({ ...prev, headers: newHeaders }))
}}
className='h-9 w-9 p-0 text-muted-foreground hover:text-foreground'
>
<X className='h-3 w-3' />
</Button>
{/* Environment Variables Dropdown for Header Key */}
{showEnvVars &&
activeInputField === 'header-key' &&
activeHeaderIndex === index && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={envSearchTerm}
inputValue={key}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}}
className='w-[380px]'
maxHeight='200px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 99999,
}}
/>
)}
{/* Environment Variables Dropdown for Header Value */}
{showEnvVars &&
activeInputField === 'header-value' &&
activeHeaderIndex === index && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={envSearchTerm}
inputValue={value}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}}
className='w-[380px]'
maxHeight='200px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 99999,
}}
/>
)}
</div>
</div>
))}
<div className='flex items-center justify-between'>
<div />
<div className='w-[380px]'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
setFormData((prev) => ({
...prev,
headers: { ...prev.headers, '': '' },
}))
}}
className='h-9 text-muted-foreground hover:text-foreground'
>
<Plus className='mr-2 h-3 w-3' />
Add Header
</Button>
</div>
</div>
<div className='border-t pt-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={handleTestConnection}
disabled={
isTestingConnection || !formData.name.trim() || !formData.url?.trim()
}
className='text-muted-foreground hover:text-foreground'
>
{isTestingConnection ? 'Testing...' : 'Test Connection'}
</Button>
{testResult?.success && (
<span className='text-green-600 text-xs'> Connected</span>
)}
</div>
<div className='flex items-center gap-2'>
{testResult && !testResult.success && (
<span className='ml-4 text-red-600 text-xs'>
{testResult.error || testResult.message}
</span>
)}
<Button variant='ghost' size='sm' onClick={() => setShowAddForm(false)}>
Cancel
</Button>
<Button
size='sm'
onClick={handleAddServer}
disabled={
serversLoading ||
isAddingServer ||
!formData.name.trim() ||
!formData.url?.trim()
}
>
{serversLoading || isAddingServer ? 'Adding...' : 'Add Server'}
</Button>
</div>
</div>
</div>
</div>
</div>
<AddServerForm
formData={formData}
testResult={testResult}
isTestingConnection={isTestingConnection}
isAddingServer={isAddingServer}
serversLoading={serversLoading}
showEnvVars={showEnvVars}
activeInputField={activeInputField}
activeHeaderIndex={activeHeaderIndex}
envSearchTerm={envSearchTerm}
cursorPosition={cursorPosition}
urlScrollLeft={urlScrollLeft}
headerScrollLeft={headerScrollLeft}
workspaceId={workspaceId}
urlInputRef={urlInputRef}
onNameChange={(value) => setFormData((prev) => ({ ...prev, name: value }))}
onInputChange={handleInputChange}
onUrlScroll={(scrollLeft) => setUrlScrollLeft(scrollLeft)}
onHeaderScroll={(key, scrollLeft) =>
setHeaderScrollLeft((prev) => ({ ...prev, [key]: scrollLeft }))
}
onEnvVarSelect={handleEnvVarSelect}
onEnvVarClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}}
onAddHeader={() =>
setFormData((prev) => ({ ...prev, headers: { ...prev.headers, '': '' } }))
}
onRemoveHeader={(key) => {
const newHeaders = { ...formData.headers }
delete newHeaders[key]
setFormData((prev) => ({ ...prev, headers: newHeaders }))
}}
onTestConnection={handleTestConnection}
onCancel={() => setShowAddForm(false)}
onAddServer={handleAddServer}
onClearTestResult={clearTestResult}
/>
) : (
!showAddForm && (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
@@ -613,7 +339,7 @@ export function MCP() {
)
)
) : (
<div className='space-y-4'>
<div className='space-y-2'>
{filteredServers.map((server: any) => {
// Add defensive checks for server properties
if (!server || !server.id) {
@@ -673,294 +399,47 @@ export function MCP() {
{/* Add Server Form for when servers exist */}
{showAddForm && (
<div className='mt-4 rounded-[8px] border bg-background p-4 shadow-xs'>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label className='font-normal'>Server Name</Label>
</div>
<div className='w-[380px]'>
<Input
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => {
if (testResult) clearTestResult()
setFormData((prev) => ({ ...prev, name: e.target.value }))
}}
className='h-9'
/>
</div>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label className='font-normal'>Server URL</Label>
</div>
<div className='relative w-[380px]'>
<Input
ref={urlInputRef}
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
value={formData.url}
onChange={(e) => handleInputChange('url', e.target.value)}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setUrlScrollLeft(scrollLeft)
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setUrlScrollLeft(scrollLeft)
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
{/* Overlay for styled text display */}
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{ transform: `translateX(-${urlScrollLeft}px)` }}
>
{formatDisplayText(formData.url || '')}
</div>
</div>
{/* Environment Variables Dropdown */}
{showEnvVars && activeInputField === 'url' && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={envSearchTerm}
inputValue={formData.url || ''}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
}}
className='w-[380px]'
maxHeight='180px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 99999,
}}
/>
)}
</div>
</div>
{Object.entries(formData.headers || {}).map(([key, value], index) => (
<div key={index} className='relative flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label className='font-normal'>Header</Label>
</div>
<div className='relative flex w-[380px] gap-2'>
{/* Header Key Input */}
<div className='relative flex-1'>
<Input
placeholder='Name'
value={key}
onChange={(e) =>
handleInputChange('header-key', e.target.value, index)
}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({
...prev,
[`key-${index}`]: scrollLeft,
}))
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({
...prev,
[`key-${index}`]: scrollLeft,
}))
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{
transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`,
}}
>
{formatDisplayText(key || '')}
</div>
</div>
</div>
{/* Header Value Input */}
<div className='relative flex-1'>
<Input
placeholder='Value'
value={value}
onChange={(e) =>
handleInputChange('header-value', e.target.value, index)
}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({
...prev,
[`value-${index}`]: scrollLeft,
}))
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({
...prev,
[`value-${index}`]: scrollLeft,
}))
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{
transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`,
}}
>
{formatDisplayText(value || '')}
</div>
</div>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => {
const newHeaders = { ...formData.headers }
delete newHeaders[key]
setFormData((prev) => ({ ...prev, headers: newHeaders }))
}}
className='h-9 w-9 p-0 text-muted-foreground hover:text-foreground'
>
<X className='h-3 w-3' />
</Button>
{/* Environment Variables Dropdown for Header Key */}
{showEnvVars &&
activeInputField === 'header-key' &&
activeHeaderIndex === index && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={envSearchTerm}
inputValue={key}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}}
className='w-[380px]'
maxHeight='200px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 99999,
}}
/>
)}
{/* Environment Variables Dropdown for Header Value */}
{showEnvVars &&
activeInputField === 'header-value' &&
activeHeaderIndex === index && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={envSearchTerm}
inputValue={value}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}}
className='w-[380px]'
maxHeight='200px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 99999,
}}
/>
)}
</div>
</div>
))}
<div className='flex items-center justify-between'>
<div />
<div className='w-[380px]'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
setFormData((prev) => ({
...prev,
headers: { ...prev.headers, '': '' },
}))
}}
className='h-9 text-muted-foreground hover:text-foreground'
>
<Plus className='mr-2 h-3 w-3' />
Add Header
</Button>
</div>
</div>
<div className='border-t pt-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={handleTestConnection}
disabled={
isTestingConnection || !formData.name.trim() || !formData.url?.trim()
}
className='text-muted-foreground hover:text-foreground'
>
{isTestingConnection ? 'Testing...' : 'Test Connection'}
</Button>
{testResult?.success && (
<span className='text-green-600 text-xs'> Connected</span>
)}
</div>
<div className='flex items-center gap-2'>
{testResult && !testResult.success && (
<span className='ml-4 text-red-600 text-xs'>
{testResult.error || testResult.message}
</span>
)}
<Button variant='ghost' size='sm' onClick={() => setShowAddForm(false)}>
Cancel
</Button>
<Button
size='sm'
onClick={handleAddServer}
disabled={
serversLoading ||
isAddingServer ||
!formData.name.trim() ||
!formData.url?.trim()
}
>
{serversLoading || isAddingServer ? 'Adding...' : 'Add Server'}
</Button>
</div>
</div>
</div>
</div>
<div className='mt-2'>
<AddServerForm
formData={formData}
testResult={testResult}
isTestingConnection={isTestingConnection}
isAddingServer={isAddingServer}
serversLoading={serversLoading}
showEnvVars={showEnvVars}
activeInputField={activeInputField}
activeHeaderIndex={activeHeaderIndex}
envSearchTerm={envSearchTerm}
cursorPosition={cursorPosition}
urlScrollLeft={urlScrollLeft}
headerScrollLeft={headerScrollLeft}
workspaceId={workspaceId}
urlInputRef={urlInputRef}
onNameChange={(value) => setFormData((prev) => ({ ...prev, name: value }))}
onInputChange={handleInputChange}
onUrlScroll={(scrollLeft) => setUrlScrollLeft(scrollLeft)}
onHeaderScroll={(key, scrollLeft) =>
setHeaderScrollLeft((prev) => ({ ...prev, [key]: scrollLeft }))
}
onEnvVarSelect={handleEnvVarSelect}
onEnvVarClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}}
onAddHeader={() =>
setFormData((prev) => ({ ...prev, headers: { ...prev.headers, '': '' } }))
}
onRemoveHeader={(key) => {
const newHeaders = { ...formData.headers }
delete newHeaders[key]
setFormData((prev) => ({ ...prev, headers: newHeaders }))
}}
onTestConnection={handleTestConnection}
onCancel={() => setShowAddForm(false)}
onAddServer={handleAddServer}
onClearTestResult={clearTestResult}
/>
</div>
)}
</div>

View File

@@ -0,0 +1,16 @@
import type { McpTransport } from '@/lib/mcp/types'
export interface McpServerFormData {
name: string
transport: McpTransport
url?: string
timeout?: number
headers?: Record<string, string>
}
export interface McpServerTestResult {
success: boolean
message?: string
error?: string
warnings?: string[]
}

View File

@@ -6,9 +6,6 @@ import type { McpTransport } from '@/lib/mcp/types'
const logger = createLogger('useMcpServerTest')
/**
* Check if transport type requires a URL
*/
function isUrlBasedTransport(transport: McpTransport): boolean {
return transport === 'streamable-http'
}
@@ -84,12 +81,23 @@ export function useMcpServerTest() {
const result = await response.json()
if (!response.ok) {
if (result.data?.error || result.data?.success === false) {
const testResult: McpServerTestResult = {
success: false,
message: result.data.error || 'Connection failed',
error: result.data.error,
warnings: result.data.warnings,
}
setTestResult(testResult)
logger.error('MCP server test failed:', result.data.error)
return testResult
}
throw new Error(result.error || 'Connection test failed')
}
setTestResult(result)
logger.info(`MCP server test ${result.success ? 'passed' : 'failed'}:`, config.name)
return result
setTestResult(result.data || result)
logger.info(`MCP server test ${result.data?.success ? 'passed' : 'failed'}:`, config.name)
return result.data || result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
const result: McpServerTestResult = {