mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(mcp): add sim tab and json config input for mcp servers
This commit is contained in:
@@ -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,109 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
}
|
||||
}, [formData, testConnection, createServerMutation, workspaceId, headersToRecord, resetForm])
|
||||
|
||||
/**
|
||||
* 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: (config.headers as Record<string, string>) || {},
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.url && typeof parsed.url === 'string') {
|
||||
setJsonError(null)
|
||||
return {
|
||||
name: '',
|
||||
url: parsed.url,
|
||||
headers: (parsed.headers as Record<string, string>) || {},
|
||||
}
|
||||
}
|
||||
|
||||
setJsonError('JSON must contain "mcpServers" or a "url" field')
|
||||
return null
|
||||
} catch {
|
||||
setJsonError('Invalid JSON')
|
||||
return null
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Adds an MCP server from parsed JSON config.
|
||||
*/
|
||||
const handleAddServerFromJson = useCallback(async () => {
|
||||
const config = parseJsonConfig(jsonInput)
|
||||
if (!config) return
|
||||
|
||||
if (!config.name) {
|
||||
setJsonError(
|
||||
'Server name is required. Use the mcpServers format: { "mcpServers": { "name": { ... } } }'
|
||||
)
|
||||
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}`)
|
||||
setJsonInput('')
|
||||
setJsonError(null)
|
||||
setAddFormMode('form')
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
logger.error('Failed to add MCP server from JSON:', error)
|
||||
} finally {
|
||||
setIsAddingServer(false)
|
||||
}
|
||||
}, [jsonInput, parseJsonConfig, testConnection, createServerMutation, workspaceId, resetForm])
|
||||
|
||||
/**
|
||||
* Opens the delete confirmation dialog for an MCP server.
|
||||
*/
|
||||
@@ -1458,102 +1568,190 @@ 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 = parseJsonConfig(jsonInput)
|
||||
if (!config) return
|
||||
if (!config.name) {
|
||||
setJsonError(
|
||||
'Server name is required. Use the mcpServers format: { "mcpServers": { "name": { ... } } }'
|
||||
)
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, 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,9 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
|
||||
|
||||
const addToWorkspaceMutation = useCreateMcpServer()
|
||||
const [addedToWorkspace, setAddedToWorkspace] = useState(false)
|
||||
|
||||
const [copiedConfig, setCopiedConfig] = useState(false)
|
||||
const [activeConfigTab, setActiveConfigTab] = useState<McpClientType>('cursor')
|
||||
const [toolToDelete, setToolToDelete] = useState<WorkflowMcpTool | null>(null)
|
||||
@@ -178,6 +182,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 +458,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 +466,124 @@ 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': '' }
|
||||
await addToWorkspaceMutation.mutateAsync({
|
||||
workspaceId,
|
||||
config: {
|
||||
name: server.name,
|
||||
transport: 'streamable-http',
|
||||
url: mcpServerUrl,
|
||||
timeout: 30000,
|
||||
headers,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
setAddedToWorkspace(true)
|
||||
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)]'>
|
||||
After adding, set your API key in Settings > MCP Tools, 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