Compare commits

...

4 Commits

2 changed files with 451 additions and 141 deletions

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ChevronDown, Plus, Search, X } from 'lucide-react'
import { Braces, ChevronDown, List, Plus, Search, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
@@ -13,6 +13,7 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Input } from '@/components/ui'
@@ -438,6 +439,9 @@ export function MCP({ initialServerId }: MCPProps) {
const [showAddForm, setShowAddForm] = useState(false)
const [formData, setFormData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
const [isAddingServer, setIsAddingServer] = useState(false)
const [addFormMode, setAddFormMode] = useState<'form' | 'json'>('form')
const [jsonInput, setJsonInput] = useState('')
const [jsonError, setJsonError] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
@@ -501,6 +505,9 @@ export function MCP({ initialServerId }: MCPProps) {
const resetForm = useCallback(() => {
setFormData(DEFAULT_FORM_DATA)
setShowAddForm(false)
setAddFormMode('form')
setJsonInput('')
setJsonError(null)
resetEnvVarState()
clearTestResult()
}, [clearTestResult, resetEnvVarState])
@@ -650,6 +657,138 @@ export function MCP({ initialServerId }: MCPProps) {
}
}, [formData, testConnection, createServerMutation, workspaceId, headersToRecord, resetForm])
/**
* Extracts string-only headers from an unknown value.
*/
const extractStringHeaders = useCallback((headers: unknown): Record<string, string> => {
if (typeof headers !== 'object' || headers === null) return {}
return Object.fromEntries(
Object.entries(headers).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
}, [])
/**
* Parses MCP JSON config into form data.
* Accepts both `{ mcpServers: { name: { url, headers } } }` and `{ url, headers }` formats.
*/
const parseJsonConfig = useCallback(
(json: string): { name: string; url: string; headers: Record<string, string> } | null => {
try {
const parsed = JSON.parse(json)
if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
const entries = Object.entries(parsed.mcpServers)
if (entries.length === 0) {
setJsonError('No servers found in mcpServers')
return null
}
const [name, config] = entries[0] as [string, Record<string, unknown>]
if (!config.url || typeof config.url !== 'string') {
setJsonError('Server config must include a "url" field')
return null
}
setJsonError(null)
return {
name,
url: config.url,
headers: extractStringHeaders(config.headers),
}
}
if (parsed.url && typeof parsed.url === 'string') {
setJsonError(null)
return {
name: '',
url: parsed.url,
headers: extractStringHeaders(parsed.headers),
}
}
setJsonError('JSON must contain "mcpServers" or a "url" field')
return null
} catch {
setJsonError('Invalid JSON')
return null
}
},
[extractStringHeaders]
)
/**
* Validates parsed JSON config for name and domain requirements.
* Returns the config if valid, null otherwise (sets jsonError on failure).
*/
const validateJsonConfig = useCallback((): {
name: string
url: string
headers: Record<string, string>
} | null => {
const config = parseJsonConfig(jsonInput)
if (!config) return null
if (!config.name) {
setJsonError(
'Server name is required. Use the mcpServers format: { "mcpServers": { "name": { ... } } }'
)
return null
}
if (!isDomainAllowed(config.url, allowedMcpDomains)) {
setJsonError('Domain not permitted by server policy')
return null
}
return config
}, [jsonInput, parseJsonConfig, allowedMcpDomains])
/**
* Adds an MCP server from parsed JSON config.
*/
const handleAddServerFromJson = useCallback(async () => {
const config = validateJsonConfig()
if (!config) return
setIsAddingServer(true)
try {
const serverConfig = {
name: config.name,
transport: 'streamable-http' as const,
url: config.url,
headers: config.headers,
timeout: 30000,
workspaceId,
}
const connectionResult = await testConnection(serverConfig)
if (!connectionResult.success) {
logger.error('Connection test failed, server not added:', connectionResult.error)
return
}
await createServerMutation.mutateAsync({
workspaceId,
config: {
name: config.name,
transport: 'streamable-http',
url: config.url,
timeout: 30000,
headers: config.headers,
enabled: true,
},
})
logger.info(`Added MCP server from JSON: ${config.name}`)
resetForm()
} catch (error) {
logger.error('Failed to add MCP server from JSON:', error)
} finally {
setIsAddingServer(false)
}
}, [validateJsonConfig, testConnection, createServerMutation, workspaceId, resetForm])
/**
* Opens the delete confirmation dialog for an MCP server.
*/
@@ -1458,102 +1597,184 @@ export function MCP({ initialServerId }: MCPProps) {
{shouldShowForm && !serversLoading && (
<div className='rounded-[8px] border p-[10px]'>
<div className='flex flex-col gap-[8px]'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => {
if (testResult) clearTestResult()
handleNameChange(e.target.value)
}}
className='h-9'
/>
</FormField>
<div className='flex items-center justify-end'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={() => {
if (testResult) clearTestResult()
setAddFormMode(addFormMode === 'form' ? 'json' : 'form')
setJsonError(null)
}}
className='h-6 w-6 p-0'
>
{addFormMode === 'form' ? (
<Braces className='h-3.5 w-3.5' />
) : (
<List className='h-3.5 w-3.5' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{addFormMode === 'form' ? 'Switch to JSON' : 'Switch to form'}
</Tooltip.Content>
</Tooltip.Root>
</div>
<FormField label='Server URL'>
<FormattedInput
ref={urlInputRef}
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
value={formData.url || ''}
scrollLeft={urlScrollLeft}
showEnvVars={showEnvVars && activeInputField === 'url'}
envVarProps={{
searchTerm: envSearchTerm,
cursorPosition,
workspaceId,
onSelect: handleEnvVarSelect,
onClose: resetEnvVarState,
}}
availableEnvVars={availableEnvVars}
onChange={(e) => handleInputChange('url', e.target.value)}
onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)}
/>
{isAddDomainBlocked && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>
Domain not permitted by server policy
</p>
)}
</FormField>
{addFormMode === 'json' ? (
<>
<Textarea
placeholder={`{\n "mcpServers": {\n "server-name": {\n "url": "https://...",\n "headers": {\n "X-API-Key": "..."\n }\n }\n }\n}`}
value={jsonInput}
onChange={(e) => {
setJsonInput(e.target.value)
if (jsonError) setJsonError(null)
if (testResult) clearTestResult()
}}
className='min-h-[160px] resize-none font-mono text-[13px]'
/>
{jsonError && <p className='text-[12px] text-[var(--text-error)]'>{jsonError}</p>}
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Headers
</span>
<Button
type='button'
variant='ghost'
onClick={handleAddHeader}
className='h-6 w-6 p-0'
>
<Plus className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center justify-between pt-[12px]'>
<Button
variant='default'
onClick={() => {
const config = validateJsonConfig()
if (!config) return
testConnection({
name: config.name,
transport: 'streamable-http',
url: config.url,
headers: config.headers,
timeout: 30000,
workspaceId,
})
}}
disabled={isTestingConnection || !jsonInput.trim()}
>
{testButtonLabel}
</Button>
<div className='flex max-h-[140px] flex-col gap-[8px] overflow-y-auto'>
{(formData.headers || []).map((header, index) => (
<HeaderRow
key={index}
header={header}
index={index}
headerScrollLeft={headerScrollLeft}
showEnvVars={showEnvVars}
activeInputField={activeInputField}
activeHeaderIndex={activeHeaderIndex}
envSearchTerm={envSearchTerm}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
onInputChange={handleInputChange}
onHeaderScroll={handleHeaderScroll}
onEnvVarSelect={handleEnvVarSelect}
onEnvVarClose={resetEnvVarState}
onRemove={() => handleRemoveHeader(index)}
<div className='flex items-center gap-[8px]'>
<Button variant='ghost' onClick={handleCancelForm}>
Cancel
</Button>
<Button
onClick={handleAddServerFromJson}
disabled={isAddingServer || !jsonInput.trim()}
variant='tertiary'
>
{isAddingServer ? 'Adding...' : 'Add Server'}
</Button>
</div>
</div>
</>
) : (
<>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => {
if (testResult) clearTestResult()
handleNameChange(e.target.value)
}}
className='h-9'
/>
))}
</div>
</div>
</FormField>
<div className='flex items-center justify-between pt-[12px]'>
<Button
variant='default'
onClick={handleTestConnection}
disabled={isTestingConnection || !isFormValid || isAddDomainBlocked}
>
{testButtonLabel}
</Button>
<FormField label='Server URL'>
<FormattedInput
ref={urlInputRef}
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
value={formData.url || ''}
scrollLeft={urlScrollLeft}
showEnvVars={showEnvVars && activeInputField === 'url'}
envVarProps={{
searchTerm: envSearchTerm,
cursorPosition,
workspaceId,
onSelect: handleEnvVarSelect,
onClose: resetEnvVarState,
}}
availableEnvVars={availableEnvVars}
onChange={(e) => handleInputChange('url', e.target.value)}
onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)}
/>
{isAddDomainBlocked && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>
Domain not permitted by server policy
</p>
)}
</FormField>
<div className='flex items-center gap-[8px]'>
<Button variant='ghost' onClick={handleCancelForm}>
Cancel
</Button>
<Button onClick={handleAddServer} disabled={isSubmitDisabled} variant='tertiary'>
{isSubmitDisabled && isFormValid && !isAddDomainBlocked
? 'Adding...'
: 'Add Server'}
</Button>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Headers
</span>
<Button
type='button'
variant='ghost'
onClick={handleAddHeader}
className='h-6 w-6 p-0'
>
<Plus className='h-3 w-3' />
</Button>
</div>
<div className='flex max-h-[140px] flex-col gap-[8px] overflow-y-auto'>
{(formData.headers || []).map((header, index) => (
<HeaderRow
key={index}
header={header}
index={index}
headerScrollLeft={headerScrollLeft}
showEnvVars={showEnvVars}
activeInputField={activeInputField}
activeHeaderIndex={activeHeaderIndex}
envSearchTerm={envSearchTerm}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
onInputChange={handleInputChange}
onHeaderScroll={handleHeaderScroll}
onEnvVarSelect={handleEnvVarSelect}
onEnvVarClose={resetEnvVarState}
onRemove={() => handleRemoveHeader(index)}
/>
))}
</div>
</div>
<div className='flex items-center justify-between pt-[12px]'>
<Button
variant='default'
onClick={handleTestConnection}
disabled={isTestingConnection || !isFormValid || isAddDomainBlocked}
>
{testButtonLabel}
</Button>
<div className='flex items-center gap-[8px]'>
<Button variant='ghost' onClick={handleCancelForm}>
Cancel
</Button>
<Button
onClick={handleAddServer}
disabled={isSubmitDisabled}
variant='tertiary'
>
{isSubmitDisabled && isFormValid && !isAddDomainBlocked
? 'Adding...'
: 'Add Server'}
</Button>
</div>
</div>
</>
)}
</div>
</div>
)}

View File

@@ -1,8 +1,8 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, Clipboard, Plus, Search } from 'lucide-react'
import { Check, Clipboard, Plus, Search, Server } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
@@ -31,6 +31,7 @@ import { Input, Skeleton } from '@/components/ui'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useApiKeys } from '@/hooks/queries/api-keys'
import { useCreateMcpServer } from '@/hooks/queries/mcp'
import {
useAddWorkflowMcpTool,
useCreateWorkflowMcpServer,
@@ -56,7 +57,7 @@ interface ServerDetailViewProps {
onBack: () => void
}
type McpClientType = 'cursor' | 'claude-code' | 'claude-desktop' | 'vscode'
type McpClientType = 'sim' | 'cursor' | 'claude-code' | 'claude-desktop' | 'vscode'
function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) {
const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId)
@@ -82,6 +83,18 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
const canManageWorkspaceKeys = userPermissions.canAdmin
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
const addToWorkspaceMutation = useCreateMcpServer()
const [addedToWorkspace, setAddedToWorkspace] = useState(false)
const addedToWorkspaceTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
useEffect(() => {
return () => {
if (addedToWorkspaceTimerRef.current) {
clearTimeout(addedToWorkspaceTimerRef.current)
}
}
}, [])
const [copiedConfig, setCopiedConfig] = useState(false)
const [activeConfigTab, setActiveConfigTab] = useState<McpClientType>('cursor')
const [toolToDelete, setToolToDelete] = useState<WorkflowMcpTool | null>(null)
@@ -178,6 +191,10 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
if (client === 'sim') {
return ''
}
if (client === 'claude-code') {
if (isPublic) {
return `claude mcp add "${safeName}" --url "${mcpServerUrl}"`
@@ -450,6 +467,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
value={activeConfigTab}
onValueChange={(v) => setActiveConfigTab(v as McpClientType)}
>
<ButtonGroupItem value='sim'>Sim</ButtonGroupItem>
<ButtonGroupItem value='cursor'>Cursor</ButtonGroupItem>
<ButtonGroupItem value='claude-code'>Claude Code</ButtonGroupItem>
<ButtonGroupItem value='claude-desktop'>Claude Desktop</ButtonGroupItem>
@@ -457,56 +475,127 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
</ButtonGroup>
</div>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<span className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Configuration
</span>
<Button
variant='ghost'
onClick={() => handleCopyConfig(server.isPublic, server.name)}
className='!p-1.5 -my-1.5'
>
{copiedConfig ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</div>
<div className='relative'>
<Code.Viewer
code={getConfigSnippet(activeConfigTab, server.isPublic, server.name)}
language={activeConfigTab === 'claude-code' ? 'javascript' : 'json'}
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
/>
{activeConfigTab === 'cursor' && (
<a
href={getCursorInstallUrl(server.isPublic, server.name)}
className='absolute top-[6px] right-2 inline-flex rounded-[6px] bg-[var(--surface-5)] ring-1 ring-[var(--border-1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]'
{activeConfigTab === 'sim' ? (
<div className='rounded-[8px] border border-[var(--border-1)] p-[16px]'>
<div className='flex flex-col gap-[12px]'>
<p className='text-[13px] text-[var(--text-secondary)]'>
Add this MCP server to your workspace so you can use its tools in other
workflows via the MCP block.
</p>
<Button
variant='tertiary'
disabled={addToWorkspaceMutation.isPending || addedToWorkspace}
onClick={async () => {
try {
const headers: Record<string, string> = server.isPublic
? {}
: { 'X-API-Key': '{{SIM_API_KEY}}' }
await addToWorkspaceMutation.mutateAsync({
workspaceId,
config: {
name: server.name,
transport: 'streamable-http',
url: mcpServerUrl,
timeout: 30000,
headers,
enabled: true,
},
})
setAddedToWorkspace(true)
addedToWorkspaceTimerRef.current = setTimeout(
() => setAddedToWorkspace(false),
3000
)
} catch (err) {
logger.error('Failed to add server to workspace:', err)
}
}}
>
<img
src='https://cursor.com/deeplink/mcp-install-dark.svg'
alt='Add to Cursor'
className='h-[26px] rounded-[6px] align-middle'
/>
</a>
{addToWorkspaceMutation.isPending ? (
'Adding...'
) : addedToWorkspace ? (
<>
<Check className='mr-[6px] h-[13px] w-[13px]' />
Added to Workspace
</>
) : (
<>
<Server className='mr-[6px] h-[13px] w-[13px]' />
Add to Workspace
</>
)}
</Button>
{!server.isPublic && (
<p className='text-[11px] text-[var(--text-muted)]'>
Set the SIM_API_KEY environment variable, or{' '}
<button
type='button'
onClick={() => setShowCreateApiKeyModal(true)}
className='underline hover:text-[var(--text-secondary)]'
>
create one now
</button>
</p>
)}
{addToWorkspaceMutation.isError && (
<p className='text-[11px] text-[var(--text-error)]'>
{addToWorkspaceMutation.error?.message || 'Failed to add server'}
</p>
)}
</div>
</div>
) : (
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<span className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Configuration
</span>
<Button
variant='ghost'
onClick={() => handleCopyConfig(server.isPublic, server.name)}
className='!p-1.5 -my-1.5'
>
{copiedConfig ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</div>
<div className='relative'>
<Code.Viewer
code={getConfigSnippet(activeConfigTab, server.isPublic, server.name)}
language={activeConfigTab === 'claude-code' ? 'javascript' : 'json'}
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
/>
{activeConfigTab === 'cursor' && (
<a
href={getCursorInstallUrl(server.isPublic, server.name)}
className='absolute top-[6px] right-2 inline-flex rounded-[6px] bg-[var(--surface-5)] ring-1 ring-[var(--border-1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]'
>
<img
src='https://cursor.com/deeplink/mcp-install-dark.svg'
alt='Add to Cursor'
className='h-[26px] rounded-[6px] align-middle'
/>
</a>
)}
</div>
{!server.isPublic && (
<p className='mt-[8px] text-[11px] text-[var(--text-muted)]'>
Replace $SIM_API_KEY with your API key, or{' '}
<button
type='button'
onClick={() => setShowCreateApiKeyModal(true)}
className='underline hover:text-[var(--text-secondary)]'
>
create one now
</button>
</p>
)}
</div>
{!server.isPublic && (
<p className='mt-[8px] text-[11px] text-[var(--text-muted)]'>
Replace $SIM_API_KEY with your API key, or{' '}
<button
type='button'
onClick={() => setShowCreateApiKeyModal(true)}
className='underline hover:text-[var(--text-secondary)]'
>
create one now
</button>
</p>
)}
</div>
)}
</div>
</SModalTabsContent>
</SModalTabsBody>