improvement(deployed-mcp): added the ability to make the visibility for deployed mcp tools public, updated UX

This commit is contained in:
waleed
2026-01-16 11:34:19 -08:00
parent fa63af9222
commit 8d9ceca1b1
16 changed files with 11350 additions and 208 deletions

View File

@@ -20,6 +20,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('WorkflowMcpServeAPI')
@@ -52,6 +53,8 @@ async function getServer(serverId: string) {
id: workflowMcpServer.id,
name: workflowMcpServer.name,
workspaceId: workflowMcpServer.workspaceId,
isPublic: workflowMcpServer.isPublic,
createdBy: workflowMcpServer.createdBy,
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.id, serverId))
@@ -90,9 +93,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!server.isPublic) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
const body = await request.json()
@@ -138,7 +143,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
id,
serverId,
rpcParams as { name: string; arguments?: Record<string, unknown> },
apiKey
apiKey,
server.isPublic ? server.createdBy : undefined
)
default:
@@ -200,7 +206,8 @@ async function handleToolsCall(
id: RequestId,
serverId: string,
params: { name: string; arguments?: Record<string, unknown> } | undefined,
apiKey?: string | null
apiKey?: string | null,
publicServerOwnerId?: string
): Promise<NextResponse> {
try {
if (!params?.name) {
@@ -243,7 +250,13 @@ async function handleToolsCall(
const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['X-API-Key'] = apiKey
if (publicServerOwnerId) {
const internalToken = await generateInternalToken(publicServerOwnerId)
headers.Authorization = `Bearer ${internalToken}`
} else if (apiKey) {
headers['X-API-Key'] = apiKey
}
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)

View File

@@ -31,6 +31,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
createdBy: workflowMcpServer.createdBy,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
isPublic: workflowMcpServer.isPublic,
createdAt: workflowMcpServer.createdAt,
updatedAt: workflowMcpServer.updatedAt,
})
@@ -98,6 +99,9 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
if (body.description !== undefined) {
updateData.description = body.description?.trim() || null
}
if (body.isPublic !== undefined) {
updateData.isPublic = body.isPublic
}
const [updatedServer] = await db
.update(workflowMcpServer)

View File

@@ -26,7 +26,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -72,7 +71,6 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -139,7 +137,6 @@ export const DELETE = withMcpAuth<RouteParams>('write')(
logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)

View File

@@ -40,7 +40,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -53,7 +52,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
// Get tools with workflow details
const tools = await db
.select({
id: workflowMcpTool.id,
@@ -107,7 +105,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -120,7 +117,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
// Verify workflow exists and is deployed
const [workflowRecord] = await db
.select({
id: workflow.id,
@@ -137,7 +133,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404)
}
// Verify workflow belongs to the same workspace
if (workflowRecord.workspaceId !== workspaceId) {
return createMcpErrorResponse(
new Error('Workflow does not belong to this workspace'),
@@ -154,7 +149,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Verify workflow has a valid start block
const hasStartBlock = await hasValidStartBlock(body.workflowId)
if (!hasStartBlock) {
return createMcpErrorResponse(
@@ -164,7 +158,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Check if tool already exists for this workflow
const [existingTool] = await db
.select({ id: workflowMcpTool.id })
.from(workflowMcpTool)
@@ -190,7 +183,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
workflowRecord.description ||
`Execute ${workflowRecord.name} workflow`
// Create the tool
const toolId = crypto.randomUUID()
const [tool] = await db
.insert(workflowMcpTool)

View File

@@ -1,10 +1,13 @@
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
const logger = createLogger('WorkflowMcpServersAPI')
@@ -25,18 +28,18 @@ export const GET = withMcpAuth('read')(
createdBy: workflowMcpServer.createdBy,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
isPublic: workflowMcpServer.isPublic,
createdAt: workflowMcpServer.createdAt,
updatedAt: workflowMcpServer.updatedAt,
toolCount: sql<number>`(
SELECT COUNT(*)::int
FROM "workflow_mcp_tool"
SELECT COUNT(*)::int
FROM "workflow_mcp_tool"
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
)`.as('tool_count'),
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.workspaceId, workspaceId))
// Fetch all tools for these servers
const serverIds = servers.map((s) => s.id)
const tools =
serverIds.length > 0
@@ -49,7 +52,6 @@ export const GET = withMcpAuth('read')(
.where(inArray(workflowMcpTool.serverId, serverIds))
: []
// Group tool names by server
const toolNamesByServer: Record<string, string[]> = {}
for (const tool of tools) {
if (!toolNamesByServer[tool.serverId]) {
@@ -58,7 +60,6 @@ export const GET = withMcpAuth('read')(
toolNamesByServer[tool.serverId].push(tool.toolName)
}
// Attach tool names to servers
const serversWithToolNames = servers.map((server) => ({
...server,
toolNames: toolNamesByServer[server.id] || [],
@@ -79,6 +80,19 @@ export const GET = withMcpAuth('read')(
}
)
/**
* Check if a workflow has a valid start block by loading from database
*/
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
return hasValidStartBlockInState(normalizedData)
} catch (error) {
logger.warn('Error checking for start block:', error)
return false
}
}
/**
* POST - Create a new workflow MCP server
*/
@@ -90,6 +104,7 @@ export const POST = withMcpAuth('write')(
logger.info(`[${requestId}] Creating workflow MCP server:`, {
name: body.name,
workspaceId,
workflowIds: body.workflowIds,
})
if (!body.name) {
@@ -110,16 +125,82 @@ export const POST = withMcpAuth('write')(
createdBy: userId,
name: body.name.trim(),
description: body.description?.trim() || null,
isPublic: body.isPublic ?? false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
// If workflowIds are provided, create tools for each workflow
const workflowIds: string[] = body.workflowIds || []
const addedTools: Array<{ workflowId: string; toolName: string }> = []
if (workflowIds.length > 0) {
// Fetch all workflows in one query
const workflows = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
isDeployed: workflow.isDeployed,
workspaceId: workflow.workspaceId,
})
.from(workflow)
.where(inArray(workflow.id, workflowIds))
// Create tools for each valid workflow
for (const workflowRecord of workflows) {
// Skip if workflow doesn't belong to this workspace
if (workflowRecord.workspaceId !== workspaceId) {
logger.warn(
`[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace`
)
continue
}
// Skip if workflow is not deployed
if (!workflowRecord.isDeployed) {
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`)
continue
}
// Skip if workflow doesn't have a start block
const hasStartBlock = await hasValidStartBlock(workflowRecord.id)
if (!hasStartBlock) {
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`)
continue
}
const toolName = sanitizeToolName(workflowRecord.name)
const toolDescription =
workflowRecord.description || `Execute ${workflowRecord.name} workflow`
const toolId = crypto.randomUUID()
await db.insert(workflowMcpTool).values({
id: toolId,
serverId,
workflowId: workflowRecord.id,
toolName,
toolDescription,
parameterSchema: {},
createdAt: new Date(),
updatedAt: new Date(),
})
addedTools.push({ workflowId: workflowRecord.id, toolName })
}
logger.info(
`[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`,
addedTools.map((t) => t.toolName)
)
}
logger.info(
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
)
return createMcpSuccessResponse({ server }, 201)
return createMcpSuccessResponse({ server, addedTools }, 201)
} catch (error) {
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
return createMcpErrorResponse(

View File

@@ -3,13 +3,17 @@ import { Label } from '@/components/emcn'
interface FormFieldProps {
label: string
children: React.ReactNode
optional?: boolean
}
export function FormField({ label, children }: FormFieldProps) {
export function FormField({ label, children, optional }: FormFieldProps) {
return (
<div className='flex items-center justify-between gap-[12px]'>
<Label className='w-[100px] shrink-0 font-medium text-[13px] text-[var(--text-secondary)]'>
{label}
{optional && (
<span className='ml-1 font-normal text-[11px] text-[var(--text-muted)]'>(optional)</span>
)}
</Label>
<div className='relative flex-1'>{children}</div>
</div>

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Search, X } from 'lucide-react'
import { ChevronDown, Plus, Search, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
@@ -77,10 +77,17 @@ interface EnvVarDropdownConfig {
onClose: () => void
}
interface McpToolSchema {
type: 'object'
properties?: Record<string, { type?: string; description?: string; items?: unknown }>
required?: string[]
}
interface McpTool {
name: string
description?: string
serverId: string
inputSchema?: McpToolSchema
}
interface McpServer {
@@ -381,6 +388,7 @@ export function MCP({ initialServerId }: MCPProps) {
const [refreshingServers, setRefreshingServers] = useState<
Record<string, { status: 'refreshing' | 'refreshed'; workflowsUpdated?: number }>
>({})
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
const [showEnvVars, setShowEnvVars] = useState(false)
const [envSearchTerm, setEnvSearchTerm] = useState('')
@@ -669,6 +677,22 @@ export function MCP({ initialServerId }: MCPProps) {
*/
const handleBackToList = useCallback(() => {
setSelectedServerId(null)
setExpandedTools(new Set())
}, [])
/**
* Toggles the expanded state of a tool's parameters.
*/
const toggleToolExpanded = useCallback((toolName: string) => {
setExpandedTools((prev) => {
const newSet = new Set(prev)
if (newSet.has(toolName)) {
newSet.delete(toolName)
} else {
newSet.add(toolName)
}
return newSet
})
}, [])
/**
@@ -843,38 +867,113 @@ export function MCP({ initialServerId }: MCPProps) {
{tools.map((tool) => {
const issues = getStoredToolIssues(server.id, tool.name)
const affectedWorkflows = issues.map((i) => i.workflowName)
const isExpanded = expandedTools.has(tool.name)
const hasParams =
tool.inputSchema?.properties &&
Object.keys(tool.inputSchema.properties).length > 0
const requiredParams = tool.inputSchema?.required || []
return (
<div
key={tool.name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
className='overflow-hidden rounded-[6px] border bg-[var(--surface-3)]'
>
<div className='flex items-center gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{issues.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<Badge
variant={getIssueBadgeVariant(issues[0].issue)}
size='sm'
className='cursor-help'
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Update in: {affectedWorkflows.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
<button
type='button'
onClick={() => hasParams && toggleToolExpanded(tool.name)}
className={cn(
'flex w-full items-start justify-between px-[10px] py-[8px] text-left',
hasParams && 'cursor-pointer hover:bg-[var(--surface-4)]'
)}
</div>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
disabled={!hasParams}
>
<div className='flex-1'>
<div className='flex items-center gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{issues.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<Badge
variant={getIssueBadgeVariant(issues[0].issue)}
size='sm'
className='cursor-help'
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Update in: {affectedWorkflows.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
)}
</div>
{hasParams && (
<ChevronDown
className={cn(
'mt-[2px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
isExpanded && 'rotate-180'
)}
/>
)}
</button>
{isExpanded && hasParams && (
<div className='border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] py-[8px]'>
<p className='mb-[6px] font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
Parameters
</p>
<div className='flex flex-col gap-[6px]'>
{Object.entries(tool.inputSchema!.properties!).map(
([paramName, param]) => {
const isRequired = requiredParams.includes(paramName)
const paramType =
typeof param === 'object' && param !== null
? (param as { type?: string }).type || 'any'
: 'any'
const paramDesc =
typeof param === 'object' && param !== null
? (param as { description?: string }).description
: undefined
return (
<div
key={paramName}
className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-3)] px-[8px] py-[6px]'
>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
{paramName}
</span>
<Badge variant='outline' size='sm'>
{paramType}
</Badge>
{isRequired && (
<Badge variant='default' size='sm'>
required
</Badge>
)}
</div>
{paramDesc && (
<p className='mt-[3px] text-[11px] text-[var(--text-tertiary)] leading-relaxed'>
{paramDesc}
</p>
)}
</div>
)
}
)}
</div>
</div>
)}
</div>
)

View File

@@ -7,6 +7,9 @@ import { useParams } from 'next/navigation'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
Code,
Combobox,
type ComboboxOption,
Input as EmcnInput,
@@ -16,22 +19,33 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
SModalTabs,
SModalTabsBody,
SModalTabsContent,
SModalTabsList,
SModalTabsTrigger,
Textarea,
Tooltip,
} from '@/components/emcn'
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 {
useAddWorkflowMcpTool,
useCreateWorkflowMcpServer,
useDeleteWorkflowMcpServer,
useDeleteWorkflowMcpTool,
useDeployedWorkflows,
useUpdateWorkflowMcpServer,
useUpdateWorkflowMcpTool,
useWorkflowMcpServer,
useWorkflowMcpServers,
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { CreateApiKeyModal } from '../api-keys/components'
import { FormField, McpServerSkeleton } from '../mcp/components'
const logger = createLogger('WorkflowMcpServers')
@@ -42,6 +56,8 @@ interface ServerDetailViewProps {
onBack: () => void
}
type McpClientType = 'cursor' | 'claude-code' | 'claude-desktop' | 'vscode'
function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) {
const { data, isLoading, error, refetch } = useWorkflowMcpServer(workspaceId, serverId)
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
@@ -49,15 +65,54 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
const deleteToolMutation = useDeleteWorkflowMcpTool()
const addToolMutation = useAddWorkflowMcpTool()
const updateToolMutation = useUpdateWorkflowMcpTool()
const [copiedUrl, setCopiedUrl] = useState(false)
const updateServerMutation = useUpdateWorkflowMcpServer()
// API Keys - for "Create API key" link
const { data: apiKeysData } = useApiKeys(workspaceId)
const { data: workspaceSettingsData } = useWorkspaceSettings(workspaceId)
const userPermissions = useUserPermissionsContext()
const [showCreateApiKeyModal, setShowCreateApiKeyModal] = useState(false)
const existingKeyNames = [
...(apiKeysData?.workspaceKeys ?? []),
...(apiKeysData?.personalKeys ?? []),
].map((k) => k.name)
const allowPersonalApiKeys =
workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true
const canManageWorkspaceKeys = userPermissions.canAdmin
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
const [copiedConfig, setCopiedConfig] = useState(false)
const [activeConfigTab, setActiveConfigTab] = useState<McpClientType>('cursor')
const [toolToDelete, setToolToDelete] = useState<WorkflowMcpTool | null>(null)
const [toolToView, setToolToView] = useState<WorkflowMcpTool | null>(null)
const [editingDescription, setEditingDescription] = useState<string>('')
const [editingParameterDescriptions, setEditingParameterDescriptions] = useState<
Record<string, string>
>({})
const [showAddWorkflow, setShowAddWorkflow] = useState(false)
const [showEditServer, setShowEditServer] = useState(false)
const [editServerName, setEditServerName] = useState('')
const [editServerDescription, setEditServerDescription] = useState('')
const [editServerIsPublic, setEditServerIsPublic] = useState(false)
const [activeServerTab, setActiveServerTab] = useState<'workflows' | 'details'>('details')
useEffect(() => {
if (toolToView) {
setEditingDescription(toolToView.toolDescription || '')
const schema = toolToView.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties
if (properties) {
const descriptions: Record<string, string> = {}
for (const [name, prop] of Object.entries(properties)) {
descriptions[name] = prop.description || ''
}
setEditingParameterDescriptions(descriptions)
} else {
setEditingParameterDescriptions({})
}
}
}, [toolToView])
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null)
@@ -66,12 +121,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
return `${getBaseUrl()}/api/mcp/serve/${serverId}`
}, [serverId])
const handleCopyUrl = () => {
navigator.clipboard.writeText(mcpServerUrl)
setCopiedUrl(true)
setTimeout(() => setCopiedUrl(false), 2000)
}
const handleDeleteTool = async () => {
if (!toolToDelete) return
try {
@@ -96,6 +145,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
})
setShowAddWorkflow(false)
setSelectedWorkflowId(null)
setActiveServerTab('workflows')
refetch()
} catch (err) {
logger.error('Failed to add workflow:', err)
@@ -108,6 +158,8 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
const existingWorkflowIds = new Set(tools.map((t) => t.workflowId))
return deployedWorkflows.filter((w) => !existingWorkflowIds.has(w.id))
}, [deployedWorkflows, tools])
const canAddWorkflow = availableWorkflows.length > 0
const showAddDisabledTooltip = !canAddWorkflow && deployedWorkflows.length > 0
const workflowOptions: ComboboxOption[] = useMemo(() => {
return availableWorkflows.map((w) => ({
@@ -120,6 +172,116 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
return availableWorkflows.find((w) => w.id === selectedWorkflowId)
}, [availableWorkflows, selectedWorkflowId])
const getConfigSnippet = useCallback(
(client: McpClientType, isPublic: boolean, serverName: string): string => {
const safeName = serverName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
if (client === 'claude-code') {
if (isPublic) {
return `claude mcp add "${safeName}" --url "${mcpServerUrl}"`
}
return `claude mcp add "${safeName}" --url "${mcpServerUrl}" --header "X-API-Key:$SIM_API_KEY"`
}
const mcpRemoteArgs = isPublic
? ['-y', 'mcp-remote', mcpServerUrl]
: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY']
const baseServerConfig = {
command: 'npx',
args: mcpRemoteArgs,
}
if (client === 'vscode') {
return JSON.stringify(
{
servers: {
[safeName]: {
type: 'stdio',
...baseServerConfig,
},
},
},
null,
2
)
}
return JSON.stringify(
{
mcpServers: {
[safeName]: baseServerConfig,
},
},
null,
2
)
},
[mcpServerUrl]
)
const handleCopyConfig = useCallback(
(isPublic: boolean, serverName: string) => {
const snippet = getConfigSnippet(activeConfigTab, isPublic, serverName)
navigator.clipboard.writeText(snippet)
setCopiedConfig(true)
setTimeout(() => setCopiedConfig(false), 2000)
},
[activeConfigTab, getConfigSnippet]
)
const handleOpenEditServer = useCallback(() => {
if (data?.server) {
setEditServerName(data.server.name)
setEditServerDescription(data.server.description || '')
setEditServerIsPublic(data.server.isPublic)
setShowEditServer(true)
}
}, [data?.server])
const handleSaveServerEdit = async () => {
if (!editServerName.trim()) return
try {
await updateServerMutation.mutateAsync({
workspaceId,
serverId,
name: editServerName.trim(),
description: editServerDescription.trim() || undefined,
isPublic: editServerIsPublic,
})
setShowEditServer(false)
refetch()
} catch (err) {
logger.error('Failed to update server:', err)
}
}
const getCursorInstallUrl = useCallback(
(isPublic: boolean, serverName: string): string => {
const safeName = serverName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
const config = isPublic
? {
command: 'npx',
args: ['-y', 'mcp-remote', mcpServerUrl],
}
: {
command: 'npx',
args: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY'],
}
const base64Config = btoa(JSON.stringify(config))
return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(safeName)}&config=${encodeURIComponent(base64Config)}`
},
[mcpServerUrl]
)
if (isLoading) {
return (
<div className='flex h-full flex-col gap-[16px]'>
@@ -148,97 +310,223 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
return (
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Server Name
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>{server.name}</p>
</div>
<SModalTabs
value={activeServerTab}
onValueChange={(value) => setActiveServerTab(value as 'workflows' | 'details')}
className='flex min-h-0 flex-1 flex-col'
>
<SModalTabsList activeValue={activeServerTab}>
<SModalTabsTrigger value='details'>Details</SModalTabsTrigger>
<SModalTabsTrigger value='workflows'>Workflows</SModalTabsTrigger>
</SModalTabsList>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Transport</span>
<p className='text-[14px] text-[var(--text-secondary)]'>Streamable-HTTP</p>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>URL</span>
<div className='flex items-center gap-[8px]'>
<p className='flex-1 break-all text-[14px] text-[var(--text-secondary)]'>
{mcpServerUrl}
</p>
<Button variant='ghost' onClick={handleCopyUrl} className='h-[32px] w-[32px] p-0'>
{copiedUrl ? (
<Check className='h-[14px] w-[14px]' />
<SModalTabsBody>
<SModalTabsContent value='workflows'>
<div className='flex flex-col gap-[16px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Workflows
</span>
{showAddDisabledTooltip ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='inline-flex'>
<Button
variant='tertiary'
onClick={() => setShowAddWorkflow(true)}
disabled
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
All deployed workflows have been added to this server.
</Tooltip.Content>
</Tooltip.Root>
) : (
<Clipboard className='h-[14px] w-[14px]' />
<Button
variant='tertiary'
onClick={() => setShowAddWorkflow(true)}
disabled={!canAddWorkflow}
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
)}
</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-primary)]'>
Workflows ({tools.length})
</span>
<Button
variant='tertiary'
onClick={() => setShowAddWorkflow(true)}
disabled={availableWorkflows.length === 0}
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
</div>
{tools.length === 0 ? (
<p className='text-[13px] text-[var(--text-muted)]'>
No workflows added yet. Click "Add" to add a deployed workflow.
</p>
) : (
<div className='flex flex-col gap-[8px]'>
{tools.map((tool) => (
<div key={tool.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{tool.toolName}</span>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{tool.toolDescription || 'No description'}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='default' onClick={() => setToolToView(tool)}>
Details
</Button>
<Button
variant='ghost'
onClick={() => setToolToDelete(tool)}
disabled={deleteToolMutation.isPending}
>
Remove
</Button>
</div>
</div>
))}
</div>
)}
{availableWorkflows.length === 0 && deployedWorkflows.length > 0 && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
All deployed workflows have been added to this server.
</p>
)}
{deployedWorkflows.length === 0 && !isLoadingWorkflows && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
Deploy a workflow first to add it to this server.
</p>
)}
</div>
{tools.length === 0 ? (
<p className='text-[13px] text-[var(--text-muted)]'>
No workflows added yet. Click "Add" to add a deployed workflow.
</p>
) : (
<div className='flex flex-col gap-[8px]'>
{tools.map((tool) => (
<div key={tool.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{tool.toolName}</span>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{tool.toolDescription || 'No description'}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='default' onClick={() => setToolToView(tool)}>
Edit
</Button>
<Button
variant='ghost'
onClick={() => setToolToDelete(tool)}
disabled={deleteToolMutation.isPending}
>
Remove
</Button>
</div>
</div>
))}
</div>
)}
{deployedWorkflows.length === 0 && !isLoadingWorkflows && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
Deploy a workflow first to add it to this server.
</p>
)}
</div>
</SModalTabsContent>
<SModalTabsContent value='details'>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Server Name
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>{server.name}</p>
</div>
{server.description?.trim() && (
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Description
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>{server.description}</p>
</div>
)}
<div className='flex gap-[24px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Transport
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>Streamable-HTTP</p>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Access
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>
{server.isPublic ? 'Public' : 'API Key'}
</p>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>URL</span>
<p className='break-all text-[14px] text-[var(--text-secondary)]'>
{mcpServerUrl}
</p>
</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)]'>
MCP Client
</span>
</div>
<ButtonGroup
value={activeConfigTab}
onValueChange={(v) => setActiveConfigTab(v as McpClientType)}
>
<ButtonGroupItem value='cursor'>Cursor</ButtonGroupItem>
<ButtonGroupItem value='claude-code'>Claude Code</ButtonGroupItem>
<ButtonGroupItem value='claude-desktop'>Claude Desktop</ButtonGroupItem>
<ButtonGroupItem value='vscode'>VS Code</ButtonGroupItem>
</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'
>
<img
src='https://cursor.com/deeplink/mcp-install-dark.svg'
alt='Add to Cursor'
className='h-[26px]'
/>
</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>
</div>
</SModalTabsContent>
</SModalTabsBody>
</SModalTabs>
<div className='mt-auto flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
{activeServerTab === 'details' && (
<>
<Button onClick={handleOpenEditServer} variant='default'>
Edit Server
</Button>
<Button
onClick={() => setShowAddWorkflow(true)}
variant='default'
disabled={!canAddWorkflow}
>
Add Workflows
</Button>
</>
)}
</div>
</div>
<div className='mt-auto flex items-center justify-end'>
<Button onClick={onBack} variant='tertiary'>
Back
</Button>
@@ -278,6 +566,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
if (!open) {
setToolToView(null)
setEditingDescription('')
setEditingParameterDescriptions({})
}
}}
>
@@ -285,10 +574,10 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
<ModalHeader>{toolToView?.toolName}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Description
</span>
</Label>
<Textarea
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
@@ -297,44 +586,58 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
/>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Parameters
</span>
{(() => {
const schema = toolToView?.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties
if (!properties || Object.keys(properties).length === 0) {
return <p className='text-[13px] text-[var(--text-muted)]'>No parameters</p>
}
return (
<div className='flex flex-col gap-[8px]'>
{Object.entries(properties).map(([name, prop]) => (
<div
key={name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{name}
</span>
<Badge variant='outline' size='sm'>
{prop.type || 'any'}
</Badge>
{(() => {
const schema = toolToView?.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties
const hasParams = properties && Object.keys(properties).length > 0
return (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Parameters
</Label>
{hasParams ? (
<div className='flex flex-col gap-[8px]'>
{Object.entries(properties).map(([name, prop]) => (
<div
key={name}
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
>
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{name}
</span>
<Badge size='sm'>{prop.type || 'any'}</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<EmcnInput
value={editingParameterDescriptions[name] || ''}
onChange={(e) =>
setEditingParameterDescriptions((prev) => ({
...prev,
[name]: e.target.value,
}))
}
placeholder={`Enter description for ${name}`}
/>
</div>
</div>
</div>
{prop.description && (
<p className='mt-[4px] text-[12px] text-[var(--text-muted)]'>
{prop.description}
</p>
)}
</div>
))}
</div>
)
})()}
</div>
))}
</div>
) : (
<p className='text-[13px] text-[var(--text-muted)]'>
No inputs configured for this workflow.
</p>
)}
</div>
)
})()}
</div>
</ModalBody>
<ModalFooter>
@@ -346,23 +649,60 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
onClick={async () => {
if (!toolToView) return
try {
const currentSchema = toolToView.parameterSchema as Record<string, unknown>
const currentProperties = (currentSchema?.properties || {}) as Record<
string,
{ type?: string; description?: string }
>
const updatedProperties: Record<string, { type?: string; description?: string }> =
{}
for (const [name, prop] of Object.entries(currentProperties)) {
updatedProperties[name] = {
...prop,
description: editingParameterDescriptions[name]?.trim() || undefined,
}
}
const updatedSchema = {
...currentSchema,
properties: updatedProperties,
}
await updateToolMutation.mutateAsync({
workspaceId,
serverId,
toolId: toolToView.id,
toolDescription: editingDescription.trim() || undefined,
parameterSchema: updatedSchema,
})
refetch()
setToolToView(null)
setEditingDescription('')
setEditingParameterDescriptions({})
} catch (err) {
logger.error('Failed to update tool description:', err)
logger.error('Failed to update tool:', err)
}
}}
disabled={
updateToolMutation.isPending ||
editingDescription.trim() === (toolToView?.toolDescription || '')
}
disabled={(() => {
if (updateToolMutation.isPending) return true
if (!toolToView) return true
const descriptionChanged =
editingDescription.trim() !== (toolToView.toolDescription || '')
const schema = toolToView.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties || {}
const paramDescriptionsChanged = Object.keys(properties).some((name) => {
const original = properties[name]?.description || ''
const edited = editingParameterDescriptions[name]?.trim() || ''
return original !== edited
})
return !descriptionChanged && !paramDescriptionsChanged
})()}
>
{updateToolMutation.isPending ? 'Saving...' : 'Save'}
</Button>
@@ -435,6 +775,83 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
</ModalFooter>
</ModalContent>
</Modal>
<Modal
open={showEditServer}
onOpenChange={(open) => {
if (!open) {
setShowEditServer(false)
}
}}
>
<ModalContent className='w-[420px]'>
<ModalHeader>Edit Server</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[12px]'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={editServerName}
onChange={(e) => setEditServerName(e.target.value)}
className='h-9'
/>
</FormField>
<FormField label='Description'>
<Textarea
placeholder='Describe what this MCP server does (optional)'
value={editServerDescription}
onChange={(e) => setEditServerDescription(e.target.value)}
className='min-h-[60px] resize-none'
/>
</FormField>
<FormField label='Access'>
<ButtonGroup
value={editServerIsPublic ? 'public' : 'private'}
onValueChange={(value) => setEditServerIsPublic(value === 'public')}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
</FormField>
<p className='text-[11px] text-[var(--text-muted)]'>
{editServerIsPublic
? 'Anyone with the URL can call this server without authentication'
: 'Requests must include your Sim API key in the X-API-Key header'}
</p>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowEditServer(false)}>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleSaveServerEdit}
disabled={
!editServerName.trim() ||
updateServerMutation.isPending ||
(editServerName === server.name &&
editServerDescription === (server.description || '') &&
editServerIsPublic === server.isPublic)
}
>
{updateServerMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<CreateApiKeyModal
open={showCreateApiKeyModal}
onOpenChange={setShowCreateApiKeyModal}
workspaceId={workspaceId}
existingKeyNames={existingKeyNames}
allowPersonalApiKeys={allowPersonalApiKeys}
canManageWorkspaceKeys={canManageWorkspaceKeys}
defaultKeyType={defaultKeyType}
/>
</>
)
}
@@ -448,12 +865,15 @@ export function WorkflowMcpServers() {
const workspaceId = params.workspaceId as string
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
useDeployedWorkflows(workspaceId)
const createServerMutation = useCreateWorkflowMcpServer()
const deleteServerMutation = useDeleteWorkflowMcpServer()
const [searchTerm, setSearchTerm] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [formData, setFormData] = useState({ name: '' })
const [formData, setFormData] = useState({ name: '', description: '', isPublic: false })
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
@@ -464,8 +884,16 @@ export function WorkflowMcpServers() {
return servers.filter((server) => server.name.toLowerCase().includes(search))
}, [servers, searchTerm])
const workflowOptions: ComboboxOption[] = useMemo(() => {
return deployedWorkflows.map((w) => ({
label: w.name,
value: w.id,
}))
}, [deployedWorkflows])
const resetForm = useCallback(() => {
setFormData({ name: '' })
setFormData({ name: '', description: '', isPublic: false })
setSelectedWorkflowIds([])
setShowAddForm(false)
}, [])
@@ -476,6 +904,9 @@ export function WorkflowMcpServers() {
await createServerMutation.mutateAsync({
workspaceId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
isPublic: formData.isPublic,
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
})
resetForm()
} catch (err) {
@@ -544,17 +975,68 @@ export function WorkflowMcpServers() {
{shouldShowForm && !isLoading && (
<div className='rounded-[8px] border p-[10px]'>
<div className='flex flex-col gap-[8px]'>
<div className='flex flex-col gap-[12px]'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => setFormData({ name: e.target.value })}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className='h-9'
/>
</FormField>
<div className='flex items-center justify-end gap-[8px] pt-[12px]'>
<FormField label='Description'>
<Textarea
placeholder='Describe what this MCP server does (optional)'
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className='min-h-[60px] resize-none'
/>
</FormField>
<FormField label='Workflows'>
<Combobox
options={workflowOptions}
multiSelect
multiSelectValues={selectedWorkflowIds}
onMultiSelectChange={setSelectedWorkflowIds}
placeholder='Select workflows...'
searchable
searchPlaceholder='Search workflows...'
isLoading={isLoadingWorkflows}
disabled={createServerMutation.isPending}
emptyMessage='No deployed workflows available'
overlayContent={
selectedWorkflowIds.length > 0 ? (
<span className='text-[var(--text-primary)]'>
{selectedWorkflowIds.length} workflow
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
</span>
) : undefined
}
/>
</FormField>
<FormField label='Access'>
<div className='flex items-center gap-[12px]'>
<ButtonGroup
value={formData.isPublic ? 'public' : 'private'}
onValueChange={(value) =>
setFormData({ ...formData, isPublic: value === 'public' })
}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
{formData.isPublic && (
<span className='text-[11px] text-[var(--text-muted)]'>
No authentication required
</span>
)}
</div>
</FormField>
<div className='flex items-center justify-end gap-[8px] pt-[4px]'>
<Button variant='ghost' onClick={resetForm}>
Cancel
</Button>
@@ -587,9 +1069,7 @@ export function WorkflowMcpServers() {
<div className='flex flex-col gap-[8px]'>
{filteredServers.map((server) => {
const count = server.toolCount || 0
const toolNames = server.toolNames || []
const names = count > 0 ? `: ${toolNames.join(', ')}` : ''
const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}${names}`
const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}`
const isDeleting = deletingServers.has(server.id)
return (
<div key={server.id} className='flex items-center justify-between gap-[12px]'>
@@ -598,9 +1078,11 @@ export function WorkflowMcpServers() {
<span className='max-w-[200px] truncate font-medium text-[14px]'>
{server.name}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(Streamable-HTTP)
</span>
{server.isPublic && (
<Badge variant='outline' size='sm'>
Public
</Badge>
)}
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>{toolsLabel}</p>
</div>

View File

@@ -93,6 +93,11 @@ export {
type SModalSidebarItemProps,
SModalSidebarSection,
SModalSidebarSectionTitle,
SModalTabs,
SModalTabsBody,
SModalTabsContent,
SModalTabsList,
SModalTabsTrigger,
SModalTrigger,
} from './s-modal/s-modal'
export { Slider, type SliderProps } from './slider/slider'

View File

@@ -26,7 +26,7 @@ import { cn } from '@/lib/core/utils/cn'
* Currently supports a 'default' variant.
*/
const inputVariants = cva(
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] font-medium font-sans text-sm text-foreground transition-colors placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {

View File

@@ -26,6 +26,7 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { X } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
import { Button } from '../button/button'
@@ -211,7 +212,7 @@ const SModalMain = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
<div
ref={ref}
className={cn(
'flex min-w-0 flex-1 flex-col gap-[16px] rounded-[8px] border-l bg-[var(--surface-2)] p-[14px]',
'flex min-w-0 flex-1 flex-col gap-[16px] overflow-hidden rounded-[8px] border-l bg-[var(--surface-2)] p-[14px]',
className
)}
{...props}
@@ -245,12 +246,146 @@ SModalMainHeader.displayName = 'SModalMainHeader'
*/
const SModalMainBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('min-w-0 flex-1 overflow-y-auto', className)} {...props} />
<div
ref={ref}
className={cn('min-w-0 flex-1 overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
)
)
SModalMainBody.displayName = 'SModalMainBody'
/**
* Sidebar modal tabs root component.
*/
const SModalTabs = TabsPrimitive.Root
interface SModalTabsListProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> {
/** Currently active tab value for indicator positioning */
activeValue?: string
/**
* Whether the tabs are disabled (non-interactive with reduced opacity)
* @default false
*/
disabled?: boolean
}
/**
* Sidebar modal tabs list component with animated indicator.
*/
const SModalTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
SModalTabsListProps
>(({ className, children, activeValue, disabled = false, ...props }, ref) => {
const listRef = React.useRef<HTMLDivElement>(null)
const [indicator, setIndicator] = React.useState({ left: 0, width: 0 })
const [ready, setReady] = React.useState(false)
React.useEffect(() => {
const list = listRef.current
if (!list) return
const updateIndicator = () => {
const activeTab = list.querySelector('[data-state="active"]') as HTMLElement | null
if (!activeTab) return
setIndicator({
left: activeTab.offsetLeft,
width: activeTab.offsetWidth,
})
setReady(true)
}
updateIndicator()
const observer = new MutationObserver(updateIndicator)
observer.observe(list, { attributes: true, subtree: true, attributeFilter: ['data-state'] })
window.addEventListener('resize', updateIndicator)
return () => {
observer.disconnect()
window.removeEventListener('resize', updateIndicator)
}
}, [activeValue])
return (
<TabsPrimitive.List
ref={ref}
className={cn(
'relative flex gap-[16px] px-4',
disabled && 'pointer-events-none opacity-50',
className
)}
{...props}
>
<div ref={listRef} className='flex gap-[16px]'>
{children}
</div>
<span
className={cn(
'pointer-events-none absolute bottom-0 h-[1px] rounded-full bg-[var(--text-primary)]',
ready && 'transition-all duration-200 ease-out'
)}
style={{ left: indicator.left, width: indicator.width }}
/>
</TabsPrimitive.List>
)
})
SModalTabsList.displayName = 'SModalTabsList'
/**
* Sidebar modal tab trigger component.
*/
const SModalTabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'px-1 pb-[8px] font-medium text-[13px] text-[var(--text-secondary)] transition-colors',
'hover:text-[var(--text-primary)] data-[state=active]:text-[var(--text-primary)]',
className
)}
{...props}
/>
))
SModalTabsTrigger.displayName = 'SModalTabsTrigger'
/**
* Sidebar modal tab content component.
*/
const SModalTabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content ref={ref} className={cn('pb-[10px]', className)} {...props} />
))
SModalTabsContent.displayName = 'SModalTabsContent'
/**
* Sidebar modal tabs body container with border-top divider.
* Wraps tab content panels to provide consistent styling with ModalBody.
*/
const SModalTabsBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'min-h-0 flex-1 overflow-y-auto border-[var(--border)] border-t pt-[10px]',
className
)}
{...props}
/>
)
)
SModalTabsBody.displayName = 'SModalTabsBody'
export {
SModal,
SModalTrigger,
@@ -264,4 +399,9 @@ export {
SModalMain,
SModalMainHeader,
SModalMainBody,
SModalTabs,
SModalTabsList,
SModalTabsTrigger,
SModalTabsContent,
SModalTabsBody,
}

View File

@@ -34,6 +34,7 @@ export interface WorkflowMcpServer {
createdBy: string
name: string
description: string | null
isPublic: boolean
createdAt: string
updatedAt: string
toolCount?: number
@@ -166,17 +167,25 @@ interface CreateWorkflowMcpServerParams {
workspaceId: string
name: string
description?: string
isPublic?: boolean
workflowIds?: string[]
}
export function useCreateWorkflowMcpServer() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, name, description }: CreateWorkflowMcpServerParams) => {
mutationFn: async ({
workspaceId,
name,
description,
isPublic,
workflowIds,
}: CreateWorkflowMcpServerParams) => {
const response = await fetch('/api/mcp/workflow-servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, name, description }),
body: JSON.stringify({ workspaceId, name, description, isPublic, workflowIds }),
})
const data = await response.json()
@@ -204,6 +213,7 @@ interface UpdateWorkflowMcpServerParams {
serverId: string
name?: string
description?: string
isPublic?: boolean
}
export function useUpdateWorkflowMcpServer() {
@@ -215,13 +225,14 @@ export function useUpdateWorkflowMcpServer() {
serverId,
name,
description,
isPublic,
}: UpdateWorkflowMcpServerParams) => {
const response = await fetch(
`/api/mcp/workflow-servers/${serverId}?workspaceId=${workspaceId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
body: JSON.stringify({ name, description, isPublic }),
}
)

View File

@@ -0,0 +1 @@
ALTER TABLE "workflow_mcp_server" ADD COLUMN "is_public" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1002,6 +1002,13 @@
"when": 1768518143986,
"tag": "0143_puzzling_xorn",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1768582494384,
"tag": "0144_old_killer_shrike",
"breakpoints": true
}
]
}

View File

@@ -1734,7 +1734,8 @@ export const ssoProvider = pgTable(
/**
* Workflow MCP Servers - User-created MCP servers that expose workflows as tools.
* These servers are accessible by external MCP clients via API key authentication.
* These servers are accessible by external MCP clients via API key authentication,
* or publicly if isPublic is set to true.
*/
export const workflowMcpServer = pgTable(
'workflow_mcp_server',
@@ -1748,6 +1749,7 @@ export const workflowMcpServer = pgTable(
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
isPublic: boolean('is_public').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},