mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
15 Commits
staging
...
waleedlati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
463ef80490 | ||
|
|
6a3e350ce4 | ||
|
|
9b72b5c83b | ||
|
|
e41fbcc266 | ||
|
|
4fd0989264 | ||
|
|
67f8a687f6 | ||
|
|
af592349d3 | ||
|
|
0d86ea01f0 | ||
|
|
115f04e989 | ||
|
|
34d92fae89 | ||
|
|
67aa4bb332 | ||
|
|
15ace5e63f | ||
|
|
fdca73679d | ||
|
|
da46a387c9 | ||
|
|
b7e377ec4b |
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user