improvement(mcp): ditch custom mcp client in favor of mcp sdk (#1780)

This commit is contained in:
Waleed
2025-10-31 11:21:00 -07:00
committed by GitHub
parent 6cd82f07ed
commit 70ff5394a4
13 changed files with 154 additions and 1250 deletions

View File

@@ -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'
}
/**

View File

@@ -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()

View File

@@ -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>
)
}

View File

@@ -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()
}
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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