mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
improvement(deployed-mcp): added the ability to make the visibility for deployed mcp tools public, updated UX
This commit is contained in:
@@ -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}`)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
1
packages/db/migrations/0144_old_killer_shrike.sql
Normal file
1
packages/db/migrations/0144_old_killer_shrike.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workflow_mcp_server" ADD COLUMN "is_public" boolean DEFAULT false NOT NULL;
|
||||
10304
packages/db/migrations/meta/0144_snapshot.json
Normal file
10304
packages/db/migrations/meta/0144_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user