From 8d9ceca1b1243a6774d8903dc6bde2d2565d4787 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 16 Jan 2026 11:34:19 -0800 Subject: [PATCH 1/7] improvement(deployed-mcp): added the ability to make the visibility for deployed mcp tools public, updated UX --- .../sim/app/api/mcp/serve/[serverId]/route.ts | 25 +- .../api/mcp/workflow-servers/[id]/route.ts | 4 + .../[id]/tools/[toolId]/route.ts | 3 - .../mcp/workflow-servers/[id]/tools/route.ts | 8 - .../sim/app/api/mcp/workflow-servers/route.ts | 95 +- .../mcp/components/form-field/form-field.tsx | 6 +- .../settings-modal/components/mcp/mcp.tsx | 155 +- .../workflow-mcp-servers.tsx | 770 +- apps/sim/components/emcn/components/index.ts | 5 + .../emcn/components/input/input.tsx | 2 +- .../emcn/components/s-modal/s-modal.tsx | 144 +- .../sim/hooks/queries/workflow-mcp-servers.ts | 17 +- .../db/migrations/0144_old_killer_shrike.sql | 1 + .../db/migrations/meta/0144_snapshot.json | 10304 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 4 +- 16 files changed, 11346 insertions(+), 204 deletions(-) create mode 100644 packages/db/migrations/0144_old_killer_shrike.sql create mode 100644 packages/db/migrations/meta/0144_snapshot.json diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index cc9ec0272f..baa33e205f 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -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 }, - apiKey + apiKey, + server.isPublic ? server.createdBy : undefined ) default: @@ -200,7 +206,8 @@ async function handleToolsCall( id: RequestId, serverId: string, params: { name: string; arguments?: Record } | undefined, - apiKey?: string | null + apiKey?: string | null, + publicServerOwnerId?: string ): Promise { try { if (!params?.name) { @@ -243,7 +250,13 @@ async function handleToolsCall( const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute` const headers: Record = { '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}`) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index 62266b817a..3ce0e00455 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -31,6 +31,7 @@ export const GET = withMcpAuth('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('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) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 4398bd4e53..d7fd532590 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -26,7 +26,6 @@ export const GET = withMcpAuth('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('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('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) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 5c39098b0f..ff802298ef 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -40,7 +40,6 @@ export const GET = withMcpAuth('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('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('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('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('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('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('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('write')( workflowRecord.description || `Execute ${workflowRecord.name} workflow` - // Create the tool const toolId = crypto.randomUUID() const [tool] = await db .insert(workflowMcpTool) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 25258e0b21..38f082304f 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -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`( - 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 = {} 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 { + 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( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/form-field/form-field.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/form-field/form-field.tsx index 9e9f61887d..2b3c2a1f68 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/form-field/form-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/form-field/form-field.tsx @@ -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 (
{children}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index ab13093682..3dd04aeb50 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -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 + 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 >({}) + const [expandedTools, setExpandedTools] = useState>(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 (
-
-

- {tool.name} -

- {issues.length > 0 && ( - - -
- - {getIssueBadgeLabel(issues[0].issue)} - -
-
- - Update in: {affectedWorkflows.join(', ')} - -
+
- {tool.description && ( -

- {tool.description} -

+ + + {isExpanded && hasParams && ( +
+

+ Parameters +

+
+ {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 ( +
+
+ + {paramName} + + + {paramType} + + {isRequired && ( + + required + + )} +
+ {paramDesc && ( +

+ {paramDesc} +

+ )} +
+ ) + } + )} +
+
)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 0c1b6a4efe..728f7ed3c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -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('cursor') const [toolToDelete, setToolToDelete] = useState(null) const [toolToView, setToolToView] = useState(null) const [editingDescription, setEditingDescription] = useState('') + const [editingParameterDescriptions, setEditingParameterDescriptions] = useState< + Record + >({}) 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 } + | undefined + const properties = schema?.properties + if (properties) { + const descriptions: Record = {} + for (const [name, prop] of Object.entries(properties)) { + descriptions[name] = prop.description || '' + } + setEditingParameterDescriptions(descriptions) + } else { + setEditingParameterDescriptions({}) + } } }, [toolToView]) const [selectedWorkflowId, setSelectedWorkflowId] = useState(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 (
@@ -148,97 +310,223 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro return ( <>
-
-
-
- - Server Name - -

{server.name}

-
- -
- Transport -

Streamable-HTTP

-
+ setActiveServerTab(value as 'workflows' | 'details')} + className='flex min-h-0 flex-1 flex-col' + > + + Details + Workflows + -
- URL -
-

- {mcpServerUrl} -

- +
+ + + All deployed workflows have been added to this server. + + ) : ( - + )} - -
-
+
-
-
- - Workflows ({tools.length}) - - + {tools.length === 0 ? ( +

+ No workflows added yet. Click "Add" to add a deployed workflow. +

+ ) : ( +
+ {tools.map((tool) => ( +
+
+ {tool.toolName} +

+ {tool.toolDescription || 'No description'} +

+
+
+ + +
+
+ ))} +
+ )} + + {deployedWorkflows.length === 0 && !isLoadingWorkflows && ( +

+ Deploy a workflow first to add it to this server. +

+ )}
+ - {tools.length === 0 ? ( -

- No workflows added yet. Click "Add" to add a deployed workflow. -

- ) : ( + +
- {tools.map((tool) => ( -
-
- {tool.toolName} -

- {tool.toolDescription || 'No description'} -

-
-
- - -
-
- ))} + + Server Name + +

{server.name}

- )} - {availableWorkflows.length === 0 && deployedWorkflows.length > 0 && ( -

- All deployed workflows have been added to this server. -

- )} - {deployedWorkflows.length === 0 && !isLoadingWorkflows && ( -

- Deploy a workflow first to add it to this server. -

- )} -
-
-
+ {server.description?.trim() && ( +
+ + Description + +

{server.description}

+
+ )} + +
+
+ + Transport + +

Streamable-HTTP

+
+
+ + Access + +

+ {server.isPublic ? 'Public' : 'API Key'} +

+
+
+ +
+ URL +

+ {mcpServerUrl} +

+
+ +
+
+ + MCP Client + +
+ setActiveConfigTab(v as McpClientType)} + > + Cursor + Claude Code + Claude Desktop + VS Code + +
+ +
+
+ + Configuration + + +
+
+ + {activeConfigTab === 'cursor' && ( + + Add to Cursor + + )} +
+ {!server.isPublic && ( +

+ Replace $SIM_API_KEY with your API key, or{' '} + +

+ )} +
+
+ + + -
+
+
+ {activeServerTab === 'details' && ( + <> + + + + )} +
@@ -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 {toolToView?.toolName}
-
- +
+