Compare commits

...

15 Commits

Author SHA1 Message Date
Waleed Latif
463ef80490 improvement(mcp): extract extractStringHeaders helper for DRY 2026-02-27 14:13:24 -08:00
Waleed Latif
6a3e350ce4 fix(mcp): deduplicate json validation, remove redundant state resets, clean up timer 2026-02-27 14:10:51 -08:00
Waleed Latif
9b72b5c83b fix(mcp): validate json headers, add domain check, fix empty api key header 2026-02-27 13:35:46 -08:00
Waleed Latif
e41fbcc266 feat(mcp): add sim tab and json config input for mcp servers 2026-02-27 13:13:10 -08:00
Waleed
4fd0989264 v0.5.101: circular dependency mitigation, confluence enhancements, google tasks and bigquery integrations, workflow lock 2026-02-26 15:04:53 -08:00
Waleed
67f8a687f6 v0.5.100: multiple credentials, 40% speedup, gong, attio, audit log improvements 2026-02-25 00:28:25 -08:00
Waleed
af592349d3 v0.5.99: local dev improvements, live workflow logs in terminal 2026-02-23 00:24:49 -08:00
Waleed
0d86ea01f0 v0.5.98: change detection improvements, rate limit and code execution fixes, removed retired models, hex integration 2026-02-21 18:07:40 -08:00
Waleed
115f04e989 v0.5.97: oidc discovery for copilot mcp 2026-02-21 02:06:25 -08:00
Waleed
34d92fae89 v0.5.96: sim oauth provider, slack ephemeral message tool and blockkit support 2026-02-20 18:22:20 -08:00
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tables): added tables (#2867)

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing

* fix(workflows): disallow duplicate workflow names at the same folder level (#3260)

* feat(tools): added redis, upstash, algolia, and revenuecat (#3261)

* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment

* feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263)

* fix(audit-log): lazily resolve actor name/email when missing (#3262)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.

* improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)

* fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)

* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* fix(tables): hide tables from sidebar and block registry (#3270)

* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint

* fix(trigger): update node version to align with main app (#3272)

* fix(build): fix corrupted sticky disk cache on blacksmith (#3273)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-02-20 13:43:07 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
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>