mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-30 01:07:59 -05:00
improvement(mcp): ditch custom mcp client in favor of mcp sdk (#1780)
This commit is contained in:
@@ -17,7 +17,7 @@ export const dynamic = 'force-dynamic'
|
||||
* Check if transport type requires a URL
|
||||
*/
|
||||
function isUrlBasedTransport(transport: McpTransport): boolean {
|
||||
return transport === 'http' || transport === 'sse' || transport === 'streamable-http'
|
||||
return transport === 'streamable-http'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,9 +13,10 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Check if transport type requires a URL
|
||||
* All modern MCP connections use Streamable HTTP which requires a URL
|
||||
*/
|
||||
function isUrlBasedTransport(transport: McpTransport): boolean {
|
||||
return transport === 'http' || transport === 'sse' || transport === 'streamable-http'
|
||||
return transport === 'streamable-http'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,16 +152,21 @@ export const POST = withMcpAuth('write')(
|
||||
client = new McpClient(testConfig, testSecurityPolicy)
|
||||
await client.connect()
|
||||
|
||||
result.success = true
|
||||
result.negotiatedVersion = client.getNegotiatedVersion()
|
||||
|
||||
try {
|
||||
const tools = await client.listTools()
|
||||
result.toolCount = tools.length
|
||||
result.success = true
|
||||
} catch (toolError) {
|
||||
logger.warn(`[${requestId}] Could not list tools from test server:`, toolError)
|
||||
logger.warn(`[${requestId}] Connection established but could not list tools:`, toolError)
|
||||
result.success = false
|
||||
const errorMessage = toolError instanceof Error ? toolError.message : 'Unknown error'
|
||||
result.error = `Connection established but could not list tools: ${errorMessage}`
|
||||
result.warnings = result.warnings || []
|
||||
result.warnings.push('Could not list tools from server')
|
||||
result.warnings.push(
|
||||
'Server connected but tool listing failed - connection may be incomplete'
|
||||
)
|
||||
}
|
||||
|
||||
const clientVersionInfo = McpClient.getVersionInfo()
|
||||
|
||||
@@ -1,581 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
import { formatDisplayText } from '@/components/ui/formatted-text'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
|
||||
import { useMcpServersStore } from '@/stores/mcp-servers/store'
|
||||
|
||||
const logger = createLogger('McpServerModal')
|
||||
|
||||
interface McpServerModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onServerCreated?: () => void
|
||||
blockId: string
|
||||
}
|
||||
|
||||
interface McpServerFormData {
|
||||
name: string
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export function McpServerModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onServerCreated,
|
||||
blockId,
|
||||
}: McpServerModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [formData, setFormData] = useState<McpServerFormData>({
|
||||
name: '',
|
||||
transport: 'streamable-http',
|
||||
url: '',
|
||||
headers: { '': '' },
|
||||
})
|
||||
const { createServer, isLoading, error: storeError, clearError } = useMcpServersStore()
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
// MCP server testing
|
||||
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
|
||||
|
||||
// Environment variable dropdown state
|
||||
const [showEnvVars, setShowEnvVars] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [activeInputField, setActiveInputField] = useState<
|
||||
'url' | 'header-key' | 'header-value' | null
|
||||
>(null)
|
||||
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
const error = localError || storeError
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
transport: 'streamable-http',
|
||||
url: '',
|
||||
headers: { '': '' },
|
||||
})
|
||||
setLocalError(null)
|
||||
clearError()
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
clearTestResult()
|
||||
}
|
||||
|
||||
// Handle environment variable selection
|
||||
const handleEnvVarSelect = useCallback(
|
||||
(newValue: string) => {
|
||||
if (activeInputField === 'url') {
|
||||
setFormData((prev) => ({ ...prev, url: newValue }))
|
||||
} else if (activeInputField === 'header-key' && activeHeaderIndex !== null) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [oldKey, value] = headerEntries[activeHeaderIndex]
|
||||
const newHeaders = { ...formData.headers }
|
||||
delete newHeaders[oldKey]
|
||||
newHeaders[newValue.replace(/[{}]/g, '')] = value
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
} else if (activeInputField === 'header-value' && activeHeaderIndex !== null) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [key] = headerEntries[activeHeaderIndex]
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
headers: { ...prev.headers, [key]: newValue },
|
||||
}))
|
||||
}
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
},
|
||||
[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
|
||||
const pos = input?.selectionStart || 0
|
||||
|
||||
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)
|
||||
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
|
||||
if (envVarTrigger.show) {
|
||||
setActiveInputField(field)
|
||||
setActiveHeaderIndex(headerIndex ?? null)
|
||||
} else {
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
}
|
||||
|
||||
// Update form data
|
||||
if (field === 'url') {
|
||||
setFormData((prev) => ({ ...prev, url: value }))
|
||||
} else if (field === 'header-key' && headerIndex !== undefined) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [oldKey, headerValue] = headerEntries[headerIndex]
|
||||
const newHeaders = { ...formData.headers }
|
||||
delete newHeaders[oldKey]
|
||||
newHeaders[value] = headerValue
|
||||
|
||||
// Add a new empty header row if this is the last row and both key and value have content
|
||||
const isLastRow = headerIndex === headerEntries.length - 1
|
||||
const hasContent = value.trim() !== '' && headerValue.trim() !== ''
|
||||
if (isLastRow && hasContent) {
|
||||
newHeaders[''] = ''
|
||||
}
|
||||
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
} else if (field === 'header-value' && headerIndex !== undefined) {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
const [key] = headerEntries[headerIndex]
|
||||
const newHeaders = { ...formData.headers, [key]: value }
|
||||
|
||||
// Add a new empty header row if this is the last row and both key and value have content
|
||||
const isLastRow = headerIndex === headerEntries.length - 1
|
||||
const hasContent = key.trim() !== '' && value.trim() !== ''
|
||||
if (isLastRow && hasContent) {
|
||||
newHeaders[''] = ''
|
||||
}
|
||||
|
||||
setFormData((prev) => ({ ...prev, headers: newHeaders }))
|
||||
}
|
||||
},
|
||||
[formData.headers, testResult, clearTestResult]
|
||||
)
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!formData.name.trim() || !formData.url?.trim()) return
|
||||
|
||||
await testConnection({
|
||||
name: formData.name,
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
headers: formData.headers,
|
||||
timeout: 30000,
|
||||
workspaceId,
|
||||
})
|
||||
}, [formData, testConnection, workspaceId])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!formData.name.trim()) {
|
||||
setLocalError('Server name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.url?.trim()) {
|
||||
setLocalError('Server URL is required for HTTP/SSE transport')
|
||||
return
|
||||
}
|
||||
|
||||
setLocalError(null)
|
||||
clearError()
|
||||
|
||||
try {
|
||||
// If no test has been done, test first
|
||||
if (!testResult) {
|
||||
const result = await testConnection({
|
||||
name: formData.name,
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
headers: formData.headers,
|
||||
timeout: 30000,
|
||||
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
|
||||
}
|
||||
|
||||
// Filter out empty headers
|
||||
const cleanHeaders = Object.fromEntries(
|
||||
Object.entries(formData.headers || {}).filter(
|
||||
([key, value]) => key.trim() !== '' && value.trim() !== ''
|
||||
)
|
||||
)
|
||||
|
||||
await createServer(workspaceId, {
|
||||
name: formData.name.trim(),
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
timeout: 30000,
|
||||
headers: cleanHeaders,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
logger.info(`Added MCP server: ${formData.name}`)
|
||||
|
||||
// Close modal and reset form immediately after successful creation
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
onServerCreated?.()
|
||||
} catch (error) {
|
||||
logger.error('Failed to add MCP server:', error)
|
||||
setLocalError(error instanceof Error ? error.message : 'Failed to add MCP server')
|
||||
}
|
||||
}, [
|
||||
formData,
|
||||
testResult,
|
||||
testConnection,
|
||||
onOpenChange,
|
||||
onServerCreated,
|
||||
createServer,
|
||||
clearError,
|
||||
workspaceId,
|
||||
])
|
||||
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[600px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a new Model Context Protocol server to extend your workflow capabilities.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Label htmlFor='server-name'>Server Name</Label>
|
||||
<Input
|
||||
id='server-name'
|
||||
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>
|
||||
<Label htmlFor='transport'>Transport Type</Label>
|
||||
<Select
|
||||
value={formData.transport}
|
||||
onValueChange={(value: 'http' | 'sse' | 'streamable-http') => {
|
||||
if (testResult) clearTestResult()
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
transport: value,
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-9'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='streamable-http'>Streamable HTTP</SelectItem>
|
||||
<SelectItem value='http'>HTTP</SelectItem>
|
||||
<SelectItem value='sse'>Server-Sent Events</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative'>
|
||||
<Label htmlFor='server-url'>Server URL</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={urlInputRef}
|
||||
id='server-url'
|
||||
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 || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables Dropdown */}
|
||||
{showEnvVars && activeInputField === 'url' && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={handleEnvVarSelect}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={formData.url || ''}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
}}
|
||||
className='w-full'
|
||||
maxHeight='250px'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Headers (Optional)</Label>
|
||||
<div className='space-y-2'>
|
||||
{Object.entries(formData.headers || {}).map(([key, value], index) => (
|
||||
<div key={index} className='relative flex gap-2'>
|
||||
{/* Header Name 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 || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</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 || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
const headerEntries = Object.entries(formData.headers || {})
|
||||
if (headerEntries.length === 1) {
|
||||
// If this is the only header, just clear it instead of deleting
|
||||
setFormData((prev) => ({ ...prev, headers: { '': '' } }))
|
||||
} else {
|
||||
// Delete this header
|
||||
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={searchTerm}
|
||||
inputValue={key}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
}}
|
||||
className='w-full'
|
||||
maxHeight='150px'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Environment Variables Dropdown for Header Value */}
|
||||
{showEnvVars &&
|
||||
activeInputField === 'header-value' &&
|
||||
activeHeaderIndex === index && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={handleEnvVarSelect}
|
||||
searchTerm={searchTerm}
|
||||
inputValue={value}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
}}
|
||||
className='w-full'
|
||||
maxHeight='250px'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className='rounded-md bg-destructive/10 px-3 py-2 text-destructive text-sm'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Connection and Actions */}
|
||||
<div className='border-t pt-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='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>
|
||||
{testResult && !testResult.success && (
|
||||
<div className='rounded border border-red-200 bg-red-50 px-2 py-1.5 text-red-600 text-xs dark:border-red-800 dark:bg-red-950/20'>
|
||||
<div className='font-medium'>Connection failed</div>
|
||||
<div className='text-red-500 dark:text-red-400'>
|
||||
{testResult.error || testResult.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !formData.name.trim() || !formData.url?.trim()}
|
||||
>
|
||||
{isLoading ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,6 @@ const getProviderIcon = (providerName: OAuthProvider) => {
|
||||
if (!baseProviderConfig) {
|
||||
return <ExternalLink className='h-4 w-4' />
|
||||
}
|
||||
// Always use the base provider icon for a more consistent UI
|
||||
return baseProviderConfig.icon({ className: 'h-4 w-4' })
|
||||
}
|
||||
|
||||
@@ -42,7 +41,6 @@ const getProviderName = (providerName: OAuthProvider) => {
|
||||
return baseProviderConfig.name
|
||||
}
|
||||
|
||||
// Fallback: capitalize the provider name
|
||||
return providerName
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
@@ -75,7 +73,6 @@ export function ToolCredentialSelector({
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
// Update selected ID when value changes
|
||||
useEffect(() => {
|
||||
setSelectedId(value)
|
||||
}, [value])
|
||||
@@ -88,7 +85,6 @@ export function ToolCredentialSelector({
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials || [])
|
||||
|
||||
// If persisted selection is not among viewer's credentials, attempt to fetch its metadata
|
||||
if (
|
||||
value &&
|
||||
!(data.credentials || []).some((cred: Credential) => cred.id === value) &&
|
||||
@@ -127,7 +123,6 @@ export function ToolCredentialSelector({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Listen for visibility changes to update credentials when user returns from settings
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
@@ -150,15 +145,12 @@ export function ToolCredentialSelector({
|
||||
|
||||
const handleOAuthClose = () => {
|
||||
setShowOAuthModal(false)
|
||||
// Refetch credentials to include any new ones
|
||||
fetchCredentials()
|
||||
}
|
||||
|
||||
// Handle popover open to fetch fresh credentials
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (isOpen) {
|
||||
// Fetch fresh credentials when opening the dropdown
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
type CustomTool,
|
||||
CustomToolModal,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
|
||||
import { McpServerModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal'
|
||||
import { McpToolsList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/mcp-tools-list'
|
||||
import { ToolCommand } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/tool-command/tool-command'
|
||||
import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/tool-credential-selector'
|
||||
@@ -430,7 +429,6 @@ export function ToolInput({
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
|
||||
const [mcpServerModalOpen, setMcpServerModalOpen] = useState(false)
|
||||
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
|
||||
@@ -1274,8 +1272,10 @@ export function ToolInput({
|
||||
value='Add MCP Server'
|
||||
onSelect={() => {
|
||||
if (!isPreview) {
|
||||
setMcpServerModalOpen(true)
|
||||
setOpen(false)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('open-settings', { detail: { tab: 'mcp' } })
|
||||
)
|
||||
}
|
||||
}}
|
||||
className='mb-1 flex cursor-pointer items-center gap-2'
|
||||
@@ -1839,7 +1839,9 @@ export function ToolInput({
|
||||
value='Add MCP Server'
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
setMcpServerModalOpen(true)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('open-settings', { detail: { tab: 'mcp' } })
|
||||
)
|
||||
}}
|
||||
className='mb-1 flex cursor-pointer items-center gap-2'
|
||||
>
|
||||
@@ -1976,17 +1978,6 @@ export function ToolInput({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* MCP Server Modal */}
|
||||
<McpServerModal
|
||||
open={mcpServerModalOpen}
|
||||
onOpenChange={setMcpServerModalOpen}
|
||||
onServerCreated={() => {
|
||||
// Refresh MCP tools when a new server is created
|
||||
refreshTools(true)
|
||||
}}
|
||||
blockId={blockId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,19 +3,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AlertCircle, Plus, Search, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Skeleton,
|
||||
} from '@/components/ui'
|
||||
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 { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -349,30 +337,6 @@ export function MCP() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='font-normal'>Transport</Label>
|
||||
</div>
|
||||
<div className='w-[380px]'>
|
||||
<Select
|
||||
value={formData.transport}
|
||||
onValueChange={(value: 'http' | 'sse' | 'streamable-http') => {
|
||||
if (testResult) clearTestResult()
|
||||
setFormData((prev) => ({ ...prev, transport: value }))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-9'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='streamable-http'>Streamable HTTP</SelectItem>
|
||||
<SelectItem value='http'>HTTP</SelectItem>
|
||||
<SelectItem value='sse'>Server-Sent Events</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='font-normal'>Server URL</Label>
|
||||
@@ -728,30 +692,6 @@ export function MCP() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='font-normal'>Transport</Label>
|
||||
</div>
|
||||
<div className='w-[380px]'>
|
||||
<Select
|
||||
value={formData.transport}
|
||||
onValueChange={(value: 'http' | 'sse' | 'streamable-http') => {
|
||||
if (testResult) clearTestResult()
|
||||
setFormData((prev) => ({ ...prev, transport: value }))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-9'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='streamable-http'>Streamable HTTP</SelectItem>
|
||||
<SelectItem value='http'>HTTP</SelectItem>
|
||||
<SelectItem value='sse'>Server-Sent Events</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='font-normal'>Server URL</Label>
|
||||
|
||||
@@ -10,7 +10,7 @@ const logger = createLogger('useMcpServerTest')
|
||||
* Check if transport type requires a URL
|
||||
*/
|
||||
function isUrlBasedTransport(transport: McpTransport): boolean {
|
||||
return transport === 'http' || transport === 'sse' || transport === 'streamable-http'
|
||||
return transport === 'streamable-http'
|
||||
}
|
||||
|
||||
export interface McpServerTestConfig {
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
/**
|
||||
* MCP (Model Context Protocol) JSON-RPC 2.0 Client
|
||||
* MCP (Model Context Protocol) Client
|
||||
*
|
||||
* Implements the client side of MCP protocol with support for:
|
||||
* - Streamable HTTP transport (MCP 2025-03-26)
|
||||
* - Connection lifecycle management
|
||||
* - Streamable HTTP transport (MCP 2025-06-18)
|
||||
* - Tool execution and discovery
|
||||
* - Session management with Mcp-Session-Id header
|
||||
* - Session management and protocol version negotiation
|
||||
* - Custom security/consent layer
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import type { ListToolsResult, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type JsonRpcRequest,
|
||||
type JsonRpcResponse,
|
||||
type McpCapabilities,
|
||||
McpConnectionError,
|
||||
type McpConnectionStatus,
|
||||
type McpConsentRequest,
|
||||
type McpConsentResponse,
|
||||
McpError,
|
||||
type McpInitializeParams,
|
||||
type McpInitializeResult,
|
||||
type McpSecurityPolicy,
|
||||
type McpServerConfig,
|
||||
McpTimeoutError,
|
||||
type McpTool,
|
||||
type McpToolCall,
|
||||
type McpToolResult,
|
||||
@@ -32,23 +29,13 @@ import {
|
||||
const logger = createLogger('McpClient')
|
||||
|
||||
export class McpClient {
|
||||
private client: Client
|
||||
private transport: StreamableHTTPClientTransport
|
||||
private config: McpServerConfig
|
||||
private connectionStatus: McpConnectionStatus
|
||||
private requestId = 0
|
||||
private pendingRequests = new Map<
|
||||
string | number,
|
||||
{
|
||||
resolve: (value: JsonRpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: NodeJS.Timeout
|
||||
}
|
||||
>()
|
||||
private serverCapabilities?: McpCapabilities
|
||||
private mcpSessionId?: string
|
||||
private negotiatedVersion?: string
|
||||
private securityPolicy: McpSecurityPolicy
|
||||
private isConnected = false
|
||||
|
||||
// Supported protocol versions
|
||||
private static readonly SUPPORTED_VERSIONS = [
|
||||
'2025-06-18', // Latest stable with elicitation and OAuth 2.1
|
||||
'2025-03-26', // Streamable HTTP support
|
||||
@@ -58,12 +45,36 @@ export class McpClient {
|
||||
constructor(config: McpServerConfig, securityPolicy?: McpSecurityPolicy) {
|
||||
this.config = config
|
||||
this.connectionStatus = { connected: false }
|
||||
|
||||
this.securityPolicy = securityPolicy ?? {
|
||||
requireConsent: true,
|
||||
auditLevel: 'basic',
|
||||
maxToolExecutionsPerHour: 1000,
|
||||
}
|
||||
|
||||
if (!this.config.url) {
|
||||
throw new McpError('URL required for Streamable HTTP transport')
|
||||
}
|
||||
|
||||
this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), {
|
||||
requestInit: {
|
||||
headers: this.config.headers,
|
||||
},
|
||||
})
|
||||
|
||||
this.client = new Client(
|
||||
{
|
||||
name: 'sim-platform',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
// Resources and prompts can be added later
|
||||
// resources: {},
|
||||
// prompts: {},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,28 +84,20 @@ export class McpClient {
|
||||
logger.info(`Connecting to MCP server: ${this.config.name} (${this.config.transport})`)
|
||||
|
||||
try {
|
||||
switch (this.config.transport) {
|
||||
case 'http':
|
||||
await this.connectStreamableHttp()
|
||||
break
|
||||
case 'sse':
|
||||
await this.connectStreamableHttp()
|
||||
break
|
||||
case 'streamable-http':
|
||||
await this.connectStreamableHttp()
|
||||
break
|
||||
default:
|
||||
throw new McpError(`Unsupported transport: ${this.config.transport}`)
|
||||
}
|
||||
await this.client.connect(this.transport)
|
||||
|
||||
await this.initialize()
|
||||
this.isConnected = true
|
||||
this.connectionStatus.connected = true
|
||||
this.connectionStatus.lastConnected = new Date()
|
||||
|
||||
logger.info(`Successfully connected to MCP server: ${this.config.name}`)
|
||||
const serverVersion = this.client.getServerVersion()
|
||||
logger.info(`Successfully connected to MCP server: ${this.config.name}`, {
|
||||
protocolVersion: serverVersion,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
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)
|
||||
}
|
||||
@@ -106,12 +109,13 @@ export class McpClient {
|
||||
async disconnect(): Promise<void> {
|
||||
logger.info(`Disconnecting from MCP server: ${this.config.name}`)
|
||||
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new McpError('Connection closed'))
|
||||
try {
|
||||
await this.client.close()
|
||||
} catch (error) {
|
||||
logger.warn(`Error during disconnect from ${this.config.name}:`, error)
|
||||
}
|
||||
this.pendingRequests.clear()
|
||||
|
||||
this.isConnected = false
|
||||
this.connectionStatus.connected = false
|
||||
logger.info(`Disconnected from MCP server: ${this.config.name}`)
|
||||
}
|
||||
@@ -127,19 +131,19 @@ export class McpClient {
|
||||
* List all available tools from the server
|
||||
*/
|
||||
async listTools(): Promise<McpTool[]> {
|
||||
if (!this.connectionStatus.connected) {
|
||||
if (!this.isConnected) {
|
||||
throw new McpConnectionError('Not connected to server', this.config.id)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.sendRequest('tools/list', {})
|
||||
const result: ListToolsResult = await this.client.listTools()
|
||||
|
||||
if (!response.tools || !Array.isArray(response.tools)) {
|
||||
logger.warn(`Invalid tools response from server ${this.config.name}:`, response)
|
||||
if (!result.tools || !Array.isArray(result.tools)) {
|
||||
logger.warn(`Invalid tools response from server ${this.config.name}:`, result)
|
||||
return []
|
||||
}
|
||||
|
||||
return response.tools.map((tool: any) => ({
|
||||
return result.tools.map((tool: Tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
@@ -156,11 +160,10 @@ export class McpClient {
|
||||
* Execute a tool on the MCP server
|
||||
*/
|
||||
async callTool(toolCall: McpToolCall): Promise<McpToolResult> {
|
||||
if (!this.connectionStatus.connected) {
|
||||
if (!this.isConnected) {
|
||||
throw new McpConnectionError('Not connected to server', this.config.id)
|
||||
}
|
||||
|
||||
// Request consent for tool execution
|
||||
const consentRequest: McpConsentRequest = {
|
||||
type: 'tool_execution',
|
||||
context: {
|
||||
@@ -171,7 +174,7 @@ export class McpClient {
|
||||
dataAccess: Object.keys(toolCall.arguments || {}),
|
||||
sideEffects: ['tool_execution'],
|
||||
},
|
||||
expires: Date.now() + 5 * 60 * 1000, // 5 minute consent window
|
||||
expires: Date.now() + 5 * 60 * 1000,
|
||||
}
|
||||
|
||||
const consentResponse = await this.requestConsent(consentRequest)
|
||||
@@ -184,16 +187,15 @@ export class McpClient {
|
||||
try {
|
||||
logger.info(`Calling tool ${toolCall.name} on server ${this.config.name}`, {
|
||||
consentAuditId: consentResponse.auditId,
|
||||
protocolVersion: this.negotiatedVersion,
|
||||
protocolVersion: this.getNegotiatedVersion(),
|
||||
})
|
||||
|
||||
const response = await this.sendRequest('tools/call', {
|
||||
const sdkResult = await this.client.callTool({
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
})
|
||||
|
||||
// The response is the JSON-RPC 'result' field
|
||||
return response as McpToolResult
|
||||
return sdkResult as McpToolResult
|
||||
} catch (error) {
|
||||
logger.error(`Failed to call tool ${toolCall.name} on server ${this.config.name}:`, error)
|
||||
throw error
|
||||
@@ -201,337 +203,31 @@ export class McpClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC request to the server
|
||||
* Ping the server to check if it's still alive and responsive
|
||||
* Per MCP spec: servers should respond to ping requests
|
||||
*/
|
||||
private async sendRequest(method: string, params: any): Promise<any> {
|
||||
const id = ++this.requestId
|
||||
const request: JsonRpcRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(id)
|
||||
reject(new McpTimeoutError(this.config.id, this.config.timeout || 30000))
|
||||
}, this.config.timeout || 30000)
|
||||
|
||||
this.pendingRequests.set(id, { resolve, reject, timeout })
|
||||
|
||||
this.sendHttpRequest(request).catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize connection with capability and version negotiation
|
||||
*/
|
||||
private async initialize(): Promise<void> {
|
||||
// Start with latest supported version for negotiation
|
||||
const preferredVersion = McpClient.SUPPORTED_VERSIONS[0]
|
||||
|
||||
const initParams: McpInitializeParams = {
|
||||
protocolVersion: preferredVersion,
|
||||
capabilities: {
|
||||
tools: { listChanged: true },
|
||||
resources: { subscribe: true, listChanged: true },
|
||||
prompts: { listChanged: true },
|
||||
logging: { level: 'info' },
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'sim-platform',
|
||||
version: '1.0.0',
|
||||
},
|
||||
async ping(): Promise<{ _meta?: Record<string, any> }> {
|
||||
if (!this.isConnected) {
|
||||
throw new McpConnectionError('Not connected to server', this.config.id)
|
||||
}
|
||||
|
||||
try {
|
||||
const result: McpInitializeResult = await this.sendRequest('initialize', initParams)
|
||||
|
||||
// Handle version negotiation
|
||||
if (result.protocolVersion !== preferredVersion) {
|
||||
// Server proposed a different version - check if we support it
|
||||
if (!McpClient.SUPPORTED_VERSIONS.includes(result.protocolVersion)) {
|
||||
// Client SHOULD disconnect if it cannot support proposed version
|
||||
throw new McpError(
|
||||
`Version negotiation failed: Server proposed unsupported version '${result.protocolVersion}'. ` +
|
||||
`This client supports versions: ${McpClient.SUPPORTED_VERSIONS.join(', ')}. ` +
|
||||
`To use this server, you may need to update your client or find a compatible version of the server.`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Version negotiation: Server proposed version '${result.protocolVersion}' ` +
|
||||
`instead of requested '${preferredVersion}'. Using server version.`
|
||||
)
|
||||
}
|
||||
|
||||
this.negotiatedVersion = result.protocolVersion
|
||||
this.serverCapabilities = result.capabilities
|
||||
|
||||
logger.info(`MCP initialization successful with protocol version '${this.negotiatedVersion}'`)
|
||||
logger.info(`[${this.config.name}] Sending ping to server`)
|
||||
const response = await this.client.ping()
|
||||
logger.info(`[${this.config.name}] Ping successful`)
|
||||
return response
|
||||
} catch (error) {
|
||||
// Enhanced error handling
|
||||
if (error instanceof McpError) {
|
||||
throw error // Re-throw MCP errors as-is
|
||||
}
|
||||
|
||||
// Handle network errors
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('fetch') || error.message.includes('network')) {
|
||||
throw new McpError(
|
||||
`Failed to connect to MCP server '${this.config.name}': ${error.message}. ` +
|
||||
`Please check the server URL and ensure the server is running.`
|
||||
)
|
||||
}
|
||||
|
||||
if (error.message.includes('timeout')) {
|
||||
throw new McpError(
|
||||
`Connection timeout to MCP server '${this.config.name}'. ` +
|
||||
`The server may be slow to respond or unreachable.`
|
||||
)
|
||||
}
|
||||
|
||||
// Generic error
|
||||
throw new McpError(
|
||||
`Connection to MCP server '${this.config.name}' failed: ${error.message}. ` +
|
||||
`Please verify the server configuration and try again.`
|
||||
)
|
||||
}
|
||||
|
||||
throw new McpError(`Unexpected error during MCP initialization: ${String(error)}`)
|
||||
}
|
||||
|
||||
await this.sendNotification('notifications/initialized', {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification
|
||||
*/
|
||||
private async sendNotification(method: string, params: any): Promise<void> {
|
||||
const notification = {
|
||||
jsonrpc: '2.0' as const,
|
||||
method,
|
||||
params,
|
||||
}
|
||||
|
||||
await this.sendHttpRequest(notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect using Streamable HTTP transport
|
||||
*/
|
||||
private async connectStreamableHttp(): Promise<void> {
|
||||
if (!this.config.url) {
|
||||
throw new McpError('URL required for Streamable HTTP transport')
|
||||
}
|
||||
|
||||
logger.info(`Using Streamable HTTP transport for ${this.config.name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send HTTP request with automatic retry
|
||||
*/
|
||||
private async sendHttpRequest(request: JsonRpcRequest | any): Promise<void> {
|
||||
if (!this.config.url) {
|
||||
throw new McpError('URL required for HTTP transport')
|
||||
}
|
||||
|
||||
const urlsToTry = [this.config.url]
|
||||
if (!this.config.url.endsWith('/')) {
|
||||
urlsToTry.push(`${this.config.url}/`)
|
||||
} else {
|
||||
urlsToTry.push(this.config.url.slice(0, -1))
|
||||
}
|
||||
|
||||
let lastError: Error | null = null
|
||||
const originalUrl = this.config.url
|
||||
|
||||
for (const [index, url] of urlsToTry.entries()) {
|
||||
try {
|
||||
await this.attemptHttpRequest(request, url, index === 0)
|
||||
|
||||
if (index > 0) {
|
||||
logger.info(
|
||||
`[${this.config.name}] Successfully used alternative URL format: ${url} (original: ${originalUrl})`
|
||||
)
|
||||
}
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
|
||||
if (error instanceof McpError && !error.message.includes('404')) {
|
||||
break
|
||||
}
|
||||
|
||||
if (index < urlsToTry.length - 1) {
|
||||
logger.info(
|
||||
`[${this.config.name}] Retrying with different URL format: ${urlsToTry[index + 1]}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new McpError('All URL variations failed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt HTTP request
|
||||
*/
|
||||
private async attemptHttpRequest(
|
||||
request: JsonRpcRequest | any,
|
||||
url: string,
|
||||
isOriginalUrl = true
|
||||
): Promise<void> {
|
||||
if (!isOriginalUrl) {
|
||||
logger.info(`[${this.config.name}] Trying alternative URL format: ${url}`)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
...this.config.headers,
|
||||
}
|
||||
|
||||
if (this.mcpSessionId) {
|
||||
headers['Mcp-Session-Id'] = this.mcpSessionId
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text().catch(() => 'Could not read response body')
|
||||
logger.error(`[${this.config.name}] HTTP request failed:`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url,
|
||||
responseBody: responseText.substring(0, 500),
|
||||
})
|
||||
throw new McpError(`HTTP request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
if ('id' in request) {
|
||||
const contentType = response.headers.get('Content-Type')
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
const sessionId = response.headers.get('Mcp-Session-Id')
|
||||
if (sessionId && !this.mcpSessionId) {
|
||||
this.mcpSessionId = sessionId
|
||||
logger.info(`[${this.config.name}] Received MCP Session ID: ${sessionId}`)
|
||||
}
|
||||
|
||||
const responseData: JsonRpcResponse = await response.json()
|
||||
this.handleResponse(responseData)
|
||||
} else if (contentType?.includes('text/event-stream')) {
|
||||
const responseText = await response.text()
|
||||
this.handleSseResponse(responseText, request.id)
|
||||
} else {
|
||||
const unexpectedType = contentType || 'unknown'
|
||||
logger.warn(`[${this.config.name}] Unexpected response content type: ${unexpectedType}`)
|
||||
|
||||
const responseText = await response.text()
|
||||
logger.debug(
|
||||
`[${this.config.name}] Unexpected response body:`,
|
||||
responseText.substring(0, 200)
|
||||
)
|
||||
|
||||
throw new McpError(
|
||||
`Unexpected response content type: ${unexpectedType}. Expected application/json or text/event-stream.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle JSON-RPC response
|
||||
*/
|
||||
private handleResponse(response: JsonRpcResponse): void {
|
||||
const pending = this.pendingRequests.get(response.id)
|
||||
if (!pending) {
|
||||
logger.warn(`Received response for unknown request ID: ${response.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingRequests.delete(response.id)
|
||||
clearTimeout(pending.timeout)
|
||||
|
||||
if (response.error) {
|
||||
const error = new McpError(response.error.message, response.error.code, response.error.data)
|
||||
pending.reject(error)
|
||||
} else {
|
||||
pending.resolve(response.result)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Server-Sent Events response format
|
||||
*/
|
||||
private handleSseResponse(responseText: string, requestId: string | number): void {
|
||||
const pending = this.pendingRequests.get(requestId)
|
||||
if (!pending) {
|
||||
logger.warn(`Received SSE response for unknown request ID: ${requestId}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse SSE format - look for data: lines
|
||||
const lines = responseText.split('\n')
|
||||
let jsonData = ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.substring(6).trim()
|
||||
if (data && data !== '[DONE]') {
|
||||
jsonData += data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!jsonData) {
|
||||
logger.error(
|
||||
`[${this.config.name}] No valid data found in SSE response for request ${requestId}`
|
||||
)
|
||||
pending.reject(new McpError('No data in SSE response'))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the JSON data
|
||||
const responseData: JsonRpcResponse = JSON.parse(jsonData)
|
||||
|
||||
this.pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
|
||||
if (responseData.error) {
|
||||
const error = new McpError(
|
||||
responseData.error.message,
|
||||
responseData.error.code,
|
||||
responseData.error.data
|
||||
)
|
||||
pending.reject(error)
|
||||
} else {
|
||||
pending.resolve(responseData.result)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${this.config.name}] Failed to parse SSE response for request ${requestId}:`, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
responseText: responseText.substring(0, 500),
|
||||
})
|
||||
|
||||
this.pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new McpError('Failed to parse SSE response'))
|
||||
logger.error(`[${this.config.name}] Ping failed:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server has capability
|
||||
*/
|
||||
hasCapability(capability: keyof McpCapabilities): boolean {
|
||||
return !!this.serverCapabilities?.[capability]
|
||||
hasCapability(capability: string): boolean {
|
||||
const serverCapabilities = this.client.getServerCapabilities()
|
||||
return !!serverCapabilities?.[capability]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -555,7 +251,8 @@ export class McpClient {
|
||||
* Get the negotiated protocol version for this connection
|
||||
*/
|
||||
getNegotiatedVersion(): string | undefined {
|
||||
return this.negotiatedVersion
|
||||
const serverVersion = this.client.getServerVersion()
|
||||
return typeof serverVersion === 'string' ? serverVersion : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -566,10 +263,8 @@ export class McpClient {
|
||||
return { granted: true, auditId: `audit-${Date.now()}` }
|
||||
}
|
||||
|
||||
// Basic security checks
|
||||
const { serverId, serverName, action, sideEffects } = consentRequest.context
|
||||
|
||||
// Check if server is in blocked
|
||||
if (this.securityPolicy.blockedOrigins?.includes(this.config.url || '')) {
|
||||
logger.warn(`Tool execution blocked: Server ${serverName} is in blocked origins`)
|
||||
return {
|
||||
@@ -578,7 +273,6 @@ export class McpClient {
|
||||
}
|
||||
}
|
||||
|
||||
// For high-risk operations, log detailed audit
|
||||
if (this.securityPolicy.auditLevel === 'detailed') {
|
||||
logger.info(`Consent requested for ${action} on ${serverName}`, {
|
||||
serverId,
|
||||
|
||||
@@ -264,7 +264,7 @@ class McpService {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description || undefined,
|
||||
transport: server.transport as 'http' | 'sse',
|
||||
transport: 'streamable-http' as const,
|
||||
url: server.url || undefined,
|
||||
headers: (server.headers as Record<string, string>) || {},
|
||||
timeout: server.timeout || 30000,
|
||||
|
||||
@@ -1,39 +1,10 @@
|
||||
/**
|
||||
* Model Context Protocol (MCP) Types
|
||||
*
|
||||
* Type definitions for JSON-RPC 2.0 based MCP implementation
|
||||
* Supporting HTTP/SSE and Streamable HTTP transports
|
||||
*/
|
||||
|
||||
// JSON-RPC 2.0 Base Types
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
method: string
|
||||
params?: any
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse<T = any> {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
result?: T
|
||||
error?: JsonRpcError
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: '2.0'
|
||||
method: string
|
||||
params?: any
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
code: number
|
||||
message: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
// MCP Transport Types
|
||||
export type McpTransport = 'http' | 'sse' | 'streamable-http'
|
||||
// Modern MCP uses Streamable HTTP which handles both HTTP POST and SSE responses
|
||||
export type McpTransport = 'streamable-http'
|
||||
|
||||
export interface McpServerConfig {
|
||||
id: string
|
||||
@@ -53,55 +24,12 @@ export interface McpServerConfig {
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
// MCP Protocol Types
|
||||
export interface McpCapabilities {
|
||||
tools?: {
|
||||
listChanged?: boolean
|
||||
}
|
||||
resources?: {
|
||||
subscribe?: boolean
|
||||
listChanged?: boolean
|
||||
}
|
||||
prompts?: {
|
||||
listChanged?: boolean
|
||||
}
|
||||
logging?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface McpInitializeParams {
|
||||
protocolVersion: string
|
||||
capabilities: McpCapabilities
|
||||
clientInfo: {
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
// Version negotiation support
|
||||
export interface McpVersionInfo {
|
||||
supported: string[] // List of supported protocol versions
|
||||
preferred: string // Preferred version to use
|
||||
}
|
||||
|
||||
export interface McpVersionNegotiationError extends JsonRpcError {
|
||||
code: -32000 // Custom error code for version negotiation failures
|
||||
message: 'Version negotiation failed'
|
||||
data: {
|
||||
clientVersions: string[]
|
||||
serverVersions: string[]
|
||||
reason: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpInitializeResult {
|
||||
protocolVersion: string
|
||||
capabilities: McpCapabilities
|
||||
serverInfo: {
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
// Security and Consent Framework
|
||||
export interface McpConsentRequest {
|
||||
type: 'tool_execution' | 'resource_access' | 'data_sharing'
|
||||
@@ -166,48 +94,11 @@ export interface McpToolResult {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// MCP Resource Types
|
||||
export interface McpResource {
|
||||
uri: string
|
||||
name: string
|
||||
description?: string
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export interface McpResourceContent {
|
||||
uri: string
|
||||
mimeType?: string
|
||||
text?: string
|
||||
blob?: string
|
||||
}
|
||||
|
||||
// MCP Prompt Types
|
||||
export interface McpPrompt {
|
||||
name: string
|
||||
description?: string
|
||||
arguments?: Array<{
|
||||
name: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export interface McpPromptMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: {
|
||||
type: 'text' | 'image' | 'resource'
|
||||
text?: string
|
||||
data?: string
|
||||
mimeType?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Connection and Error Types
|
||||
export interface McpConnectionStatus {
|
||||
connected: boolean
|
||||
lastConnected?: Date
|
||||
lastError?: string
|
||||
serverInfo?: McpInitializeResult['serverInfo']
|
||||
}
|
||||
|
||||
export class McpError extends Error {
|
||||
@@ -228,22 +119,6 @@ export class McpConnectionError extends McpError {
|
||||
}
|
||||
}
|
||||
|
||||
export class McpTimeoutError extends McpError {
|
||||
constructor(serverId: string, timeout: number) {
|
||||
super(`MCP request to server ${serverId} timed out after ${timeout}ms`)
|
||||
this.name = 'McpTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
// Integration Types (for existing platform)
|
||||
export interface McpToolInput {
|
||||
type: 'mcp'
|
||||
serverId: string
|
||||
toolName: string
|
||||
params: Record<string, any>
|
||||
usageControl?: 'auto' | 'force' | 'none'
|
||||
}
|
||||
|
||||
export interface McpServerSummary {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -7,9 +7,6 @@ export interface McpServerWithStatus {
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
timeout?: number
|
||||
retries?: number
|
||||
enabled?: boolean
|
||||
|
||||
Reference in New Issue
Block a user