improvement(mcp): improved mcp sse events notifs, update jira to handle files, fix UI issues in settings modal, fix org and workspace invitations when bundled (#3182)

* improvement(mcp): improved mcp sse events notifs, update jira to handle files, fix UI issues in settings modal, fix org and workspace invitations when bundled

* added back useMcpToolsEvents for event-driven discovery

* ack PR comments

* updated placeholder

* updated colors, error throwing in mcp modal

* ack comments

* updated error msg
This commit is contained in:
Waleed
2026-02-10 17:08:57 -08:00
committed by GitHub
parent f8e9614c9c
commit 6d16f216c8
48 changed files with 1097 additions and 365 deletions

View File

@@ -6,6 +6,7 @@ import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
@@ -170,6 +171,11 @@ export const POST = withMcpAuth<RouteParams>('write')(
workflowRecord.description ||
`Execute ${workflowRecord.name} workflow`
const parameterSchema =
body.parameterSchema && Object.keys(body.parameterSchema).length > 0
? body.parameterSchema
: await generateParameterSchemaForWorkflow(body.workflowId)
const toolId = crypto.randomUUID()
const [tool] = await db
.insert(workflowMcpTool)
@@ -179,7 +185,7 @@ export const POST = withMcpAuth<RouteParams>('write')(
workflowId: body.workflowId,
toolName,
toolDescription,
parameterSchema: body.parameterSchema || {},
parameterSchema,
createdAt: new Date(),
updatedAt: new Date(),
})

View File

@@ -6,6 +6,7 @@ import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
@@ -156,6 +157,8 @@ export const POST = withMcpAuth('write')(
const toolDescription =
workflowRecord.description || `Execute ${workflowRecord.name} workflow`
const parameterSchema = await generateParameterSchemaForWorkflow(workflowRecord.id)
const toolId = crypto.randomUUID()
await db.insert(workflowMcpTool).values({
id: toolId,
@@ -163,7 +166,7 @@ export const POST = withMcpAuth('write')(
workflowId: workflowRecord.id,
toolName,
toolDescription,
parameterSchema: {},
parameterSchema,
createdAt: new Date(),
updatedAt: new Date(),
})

View File

@@ -446,15 +446,46 @@ export async function PUT(
})
.where(eq(workspaceInvitation.id, wsInvitation.id))
await tx.insert(permissions).values({
id: randomUUID(),
entityType: 'workspace',
entityId: wsInvitation.workspaceId,
userId: session.user.id,
permissionType: wsInvitation.permissions || 'read',
createdAt: new Date(),
updatedAt: new Date(),
})
const existingPermission = await tx
.select({ id: permissions.id, permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.entityId, wsInvitation.workspaceId),
eq(permissions.entityType, 'workspace'),
eq(permissions.userId, session.user.id)
)
)
.then((rows) => rows[0])
if (existingPermission) {
const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const
type PermissionLevel = keyof typeof PERMISSION_RANK
const existingRank =
PERMISSION_RANK[existingPermission.permissionType as PermissionLevel] ?? 0
const newPermission = (wsInvitation.permissions || 'read') as PermissionLevel
const newRank = PERMISSION_RANK[newPermission] ?? 0
if (newRank > existingRank) {
await tx
.update(permissions)
.set({
permissionType: newPermission,
updatedAt: new Date(),
})
.where(eq(permissions.id, existingPermission.id))
}
} else {
await tx.insert(permissions).values({
id: randomUUID(),
entityType: 'workspace',
entityId: wsInvitation.workspaceId,
userId: session.user.id,
permissionType: wsInvitation.permissions || 'read',
createdAt: new Date(),
updatedAt: new Date(),
})
}
}
} else if (status === 'cancelled') {
await tx

View File

@@ -47,16 +47,9 @@ export async function POST(request: NextRequest) {
(await getJiraCloudId(validatedData.domain, validatedData.accessToken))
const formData = new FormData()
const filesOutput: Array<{ name: string; mimeType: string; data: string; size: number }> = []
for (const file of userFiles) {
const buffer = await downloadFileFromStorage(file, requestId, logger)
filesOutput.push({
name: file.name,
mimeType: file.type || 'application/octet-stream',
data: buffer.toString('base64'),
size: buffer.length,
})
const blob = new Blob([new Uint8Array(buffer)], {
type: file.type || 'application/octet-stream',
})
@@ -109,7 +102,7 @@ export async function POST(request: NextRequest) {
issueKey: validatedData.issueKey,
attachments,
attachmentIds,
files: filesOutput,
files: userFiles,
},
})
} catch (error) {

View File

@@ -45,6 +45,12 @@ interface McpDeployProps {
onCanSaveChange?: (canSave: boolean) => void
}
function haveSameServerSelection(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
const bSet = new Set(b)
return a.every((id) => bSet.has(id))
}
/**
* Generate JSON Schema from input format with optional descriptions
*/
@@ -143,6 +149,7 @@ export function McpDeploy({
})
const [parameterDescriptions, setParameterDescriptions] = useState<Record<string, string>>({})
const [pendingServerChanges, setPendingServerChanges] = useState<Set<string>>(new Set())
const [saveErrors, setSaveErrors] = useState<string[]>([])
const parameterSchema = useMemo(
() => generateParameterSchema(inputFormat, parameterDescriptions),
@@ -179,6 +186,7 @@ export function McpDeploy({
}
return ids
}, [servers, serverToolsMap])
const [draftSelectedServerIds, setDraftSelectedServerIds] = useState<string[] | null>(null)
const hasLoadedInitialData = useRef(false)
@@ -238,9 +246,10 @@ export function McpDeploy({
}
}, [toolName, toolDescription, parameterDescriptions, savedValues])
const hasDeployedTools = selectedServerIds.length > 0
const hasChanges = useMemo(() => {
if (!savedValues || !hasDeployedTools) return false
const selectedServerIdsForForm = draftSelectedServerIds ?? selectedServerIds
const hasToolConfigurationChanges = useMemo(() => {
if (!savedValues) return false
if (toolName !== savedValues.toolName) return true
if (toolDescription !== savedValues.toolDescription) return true
if (
@@ -249,11 +258,18 @@ export function McpDeploy({
return true
}
return false
}, [toolName, toolDescription, parameterDescriptions, hasDeployedTools, savedValues])
}, [toolName, toolDescription, parameterDescriptions, savedValues])
const hasServerSelectionChanges = useMemo(
() => !haveSameServerSelection(selectedServerIdsForForm, selectedServerIds),
[selectedServerIdsForForm, selectedServerIds]
)
const hasChanges =
hasServerSelectionChanges ||
(hasToolConfigurationChanges && selectedServerIdsForForm.length > 0)
useEffect(() => {
onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim())
}, [hasChanges, hasDeployedTools, toolName, onCanSaveChange])
onCanSaveChange?.(hasChanges && !!toolName.trim())
}, [hasChanges, toolName, onCanSaveChange])
/**
* Save tool configuration to all deployed servers
@@ -261,74 +277,25 @@ export function McpDeploy({
const handleSave = useCallback(async () => {
if (!toolName.trim()) return
const toolsToUpdate: Array<{ serverId: string; toolId: string }> = []
for (const server of servers) {
const toolInfo = serverToolsMap[server.id]
if (toolInfo?.tool) {
toolsToUpdate.push({ serverId: server.id, toolId: toolInfo.tool.id })
}
}
const currentIds = new Set(selectedServerIds)
const nextIds = new Set(selectedServerIdsForForm)
const toAdd = new Set(selectedServerIdsForForm.filter((id) => !currentIds.has(id)))
const toRemove = selectedServerIds.filter((id) => !nextIds.has(id))
const shouldUpdateExisting = hasToolConfigurationChanges
if (toolsToUpdate.length === 0) return
if (toAdd.size === 0 && toRemove.length === 0 && !shouldUpdateExisting) return
onSubmittingChange?.(true)
setSaveErrors([])
try {
for (const { serverId, toolId } of toolsToUpdate) {
await updateToolMutation.mutateAsync({
workspaceId,
serverId,
toolId,
toolName: toolName.trim(),
toolDescription: toolDescription.trim() || undefined,
parameterSchema,
})
}
// Update saved values after successful save (triggers re-render → hasChanges becomes false)
setSavedValues({
toolName,
toolDescription,
parameterDescriptions: { ...parameterDescriptions },
})
onCanSaveChange?.(false)
onSubmittingChange?.(false)
} catch (error) {
logger.error('Failed to save tool configuration:', error)
onSubmittingChange?.(false)
}
}, [
toolName,
toolDescription,
parameterDescriptions,
parameterSchema,
servers,
serverToolsMap,
workspaceId,
updateToolMutation,
onSubmittingChange,
onCanSaveChange,
])
const serverOptions: ComboboxOption[] = useMemo(() => {
return servers.map((server) => ({
label: server.name,
value: server.id,
}))
}, [servers])
const handleServerSelectionChange = useCallback(
async (newSelectedIds: string[]) => {
if (!toolName.trim()) return
const currentIds = new Set(selectedServerIds)
const newIds = new Set(newSelectedIds)
const toAdd = newSelectedIds.filter((id) => !currentIds.has(id))
const toRemove = selectedServerIds.filter((id) => !newIds.has(id))
const errors: string[] = []
const addedEntries: Record<string, { tool: WorkflowMcpTool; isLoading: boolean }> = {}
const removedIds: string[] = []
for (const serverId of toAdd) {
setPendingServerChanges((prev) => new Set(prev).add(serverId))
try {
await addToolMutation.mutateAsync({
const addedTool = await addToolMutation.mutateAsync({
workspaceId,
serverId,
workflowId,
@@ -336,10 +303,13 @@ export function McpDeploy({
toolDescription: toolDescription.trim() || undefined,
parameterSchema,
})
addedEntries[serverId] = { tool: addedTool, isLoading: false }
onAddedToServer?.()
logger.info(`Added workflow ${workflowId} as tool to server ${serverId}`)
} catch (error) {
logger.error('Failed to add tool:', error)
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
errors.push(`Failed to add to ${serverName}`)
logger.error(`Failed to add tool to server ${serverId}:`, error)
} finally {
setPendingServerChanges((prev) => {
const next = new Set(prev)
@@ -351,54 +321,115 @@ export function McpDeploy({
for (const serverId of toRemove) {
const toolInfo = serverToolsMap[serverId]
if (toolInfo?.tool) {
setPendingServerChanges((prev) => new Set(prev).add(serverId))
if (!toolInfo?.tool) continue
setPendingServerChanges((prev) => new Set(prev).add(serverId))
try {
await deleteToolMutation.mutateAsync({
workspaceId,
serverId,
toolId: toolInfo.tool.id,
})
removedIds.push(serverId)
} catch (error) {
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
errors.push(`Failed to remove from ${serverName}`)
logger.error(`Failed to remove tool from server ${serverId}:`, error)
} finally {
setPendingServerChanges((prev) => {
const next = new Set(prev)
next.delete(serverId)
return next
})
}
}
if (shouldUpdateExisting) {
for (const serverId of selectedServerIdsForForm) {
if (toAdd.has(serverId)) continue
const toolInfo = serverToolsMap[serverId]
if (!toolInfo?.tool) continue
try {
await deleteToolMutation.mutateAsync({
await updateToolMutation.mutateAsync({
workspaceId,
serverId,
toolId: toolInfo.tool.id,
})
setServerToolsMap((prev) => {
const next = { ...prev }
delete next[serverId]
return next
toolName: toolName.trim(),
toolDescription: toolDescription.trim() || undefined,
parameterSchema,
})
} catch (error) {
logger.error('Failed to remove tool:', error)
} finally {
setPendingServerChanges((prev) => {
const next = new Set(prev)
next.delete(serverId)
return next
})
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
errors.push(`Failed to update on ${serverName}`)
logger.error(`Failed to update tool on server ${serverId}:`, error)
}
}
}
},
[
selectedServerIds,
serverToolsMap,
toolName,
toolDescription,
workspaceId,
workflowId,
parameterSchema,
addToolMutation,
deleteToolMutation,
onAddedToServer,
]
)
setServerToolsMap((prev) => {
const next = { ...prev, ...addedEntries }
for (const id of removedIds) {
delete next[id]
}
return next
})
if (errors.length > 0) {
setSaveErrors(errors)
} else {
setDraftSelectedServerIds(null)
setSavedValues({
toolName,
toolDescription,
parameterDescriptions: { ...parameterDescriptions },
})
onCanSaveChange?.(false)
}
onSubmittingChange?.(false)
} catch (error) {
logger.error('Failed to save tool configuration:', error)
onSubmittingChange?.(false)
}
}, [
toolName,
toolDescription,
parameterDescriptions,
parameterSchema,
selectedServerIds,
selectedServerIdsForForm,
hasToolConfigurationChanges,
serverToolsMap,
workspaceId,
workflowId,
servers,
addToolMutation,
deleteToolMutation,
updateToolMutation,
onAddedToServer,
onSubmittingChange,
onCanSaveChange,
])
const serverOptions: ComboboxOption[] = useMemo(() => {
return servers.map((server) => ({
label: server.name,
value: server.id,
}))
}, [servers])
const handleServerSelectionChange = useCallback((newSelectedIds: string[]) => {
setDraftSelectedServerIds(newSelectedIds)
}, [])
const selectedServersLabel = useMemo(() => {
const count = selectedServerIds.length
const count = selectedServerIdsForForm.length
if (count === 0) return 'Select servers...'
if (count === 1) {
const server = servers.find((s) => s.id === selectedServerIds[0])
const server = servers.find((s) => s.id === selectedServerIdsForForm[0])
return server?.name || '1 server'
}
return `${count} servers selected`
}, [selectedServerIds, servers])
}, [selectedServerIdsForForm, servers])
const isPending = pendingServerChanges.size > 0
@@ -544,7 +575,7 @@ export function McpDeploy({
<Combobox
options={serverOptions}
multiSelect
multiSelectValues={selectedServerIds}
multiSelectValues={selectedServerIdsForForm}
onMultiSelectChange={handleServerSelectionChange}
placeholder='Select servers...'
searchable
@@ -561,10 +592,14 @@ export function McpDeploy({
)}
</div>
{addToolMutation.isError && (
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>
{addToolMutation.error?.message || 'Failed to add tool'}
</p>
{saveErrors.length > 0 && (
<div className='mt-[6.5px] flex flex-col gap-[2px]'>
{saveErrors.map((error) => (
<p key={error} className='text-[12px] text-[var(--text-error)]'>
{error}
</p>
))}
</div>
)}
</form>
)

View File

@@ -59,7 +59,7 @@ export function ToolCredentialSelector({
disabled = false,
}: ToolCredentialSelectorProps) {
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [inputValue, setInputValue] = useState('')
const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
@@ -100,11 +100,7 @@ export function ToolCredentialSelector({
return ''
}, [selectedCredential, isForeign])
useEffect(() => {
if (!isEditing) {
setInputValue(resolvedLabel)
}
}, [resolvedLabel, isEditing])
const inputValue = isEditing ? editingInputValue : resolvedLabel
const invalidSelection =
Boolean(selectedId) &&
@@ -189,13 +185,12 @@ export function ToolCredentialSelector({
const matchedCred = credentials.find((c) => c.id === newValue)
if (matchedCred) {
setInputValue(matchedCred.name)
handleSelect(newValue)
return
}
setIsEditing(true)
setInputValue(newValue)
setEditingInputValue(newValue)
},
[credentials, handleAddCredential, handleSelect]
)

View File

@@ -2642,7 +2642,7 @@ export const ToolInput = memo(function ToolInput({
</div>
{!isCustomTool && isExpandedForDisplay && (
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t px-[8px] py-[8px]'>
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[8px] py-[8px]'>
{/* Operation dropdown for tools with multiple operations */}
{(() => {
const hasOperations = hasMultipleOperations(tool.type)

View File

@@ -6,7 +6,6 @@ import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
@@ -103,12 +102,7 @@ export function CustomTools() {
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='flex items-center gap-[8px]'>
<div
className={cn(
'flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]',
isLoading && 'opacity-50'
)}
>
<div className='flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
@@ -118,7 +112,7 @@ export function CustomTools() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={isLoading}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>

View File

@@ -38,6 +38,7 @@ import {
useMcpToolsQuery,
useRefreshMcpServer,
useStoredMcpTools,
useUpdateMcpServer,
} from '@/hooks/queries/mcp'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -96,6 +97,8 @@ interface McpServer {
name?: string
transport?: string
url?: string
headers?: Record<string, string>
enabled?: boolean
connectionStatus?: 'connected' | 'disconnected' | 'error'
lastError?: string | null
lastConnected?: string
@@ -378,6 +381,13 @@ export function MCP({ initialServerId }: MCPProps) {
const deleteServerMutation = useDeleteMcpServer()
const refreshServerMutation = useRefreshMcpServer()
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
const updateServerMutation = useUpdateMcpServer()
const {
testResult: editTestResult,
isTestingConnection: isEditTestingConnection,
testConnection: editTestConnection,
clearTestResult: clearEditTestResult,
} = useMcpServerTest()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const urlInputRef = useRef<HTMLInputElement>(null)
@@ -407,6 +417,19 @@ export function MCP({ initialServerId }: MCPProps) {
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
const [showEditModal, setShowEditModal] = useState(false)
const [editFormData, setEditFormData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
const [editOriginalData, setEditOriginalData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
const [isUpdatingServer, setIsUpdatingServer] = useState(false)
const [editSaveError, setEditSaveError] = useState<string | null>(null)
const [editShowEnvVars, setEditShowEnvVars] = useState(false)
const [editEnvSearchTerm, setEditEnvSearchTerm] = useState('')
const [editCursorPosition, setEditCursorPosition] = useState(0)
const [editActiveInputField, setEditActiveInputField] = useState<InputFieldType | null>(null)
const [editActiveHeaderIndex, setEditActiveHeaderIndex] = useState<number | null>(null)
const [editUrlScrollLeft, setEditUrlScrollLeft] = useState(0)
const [editHeaderScrollLeft, setEditHeaderScrollLeft] = useState<Record<string, number>>({})
useEffect(() => {
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
setSelectedServerId(initialServerId)
@@ -757,6 +780,215 @@ export function MCP({ initialServerId }: MCPProps) {
[refreshServerMutation, workspaceId]
)
/**
* Resets edit modal environment variable dropdown state.
*/
const resetEditEnvVarState = useCallback(() => {
setEditShowEnvVars(false)
setEditActiveInputField(null)
setEditActiveHeaderIndex(null)
}, [])
/**
* Opens the edit modal and populates form with current server data.
*/
const handleOpenEditModal = useCallback(
(server: McpServer) => {
const headers: HeaderEntry[] = server.headers
? Object.entries(server.headers).map(([key, value]) => ({ key, value }))
: [{ key: '', value: '' }]
if (headers.length === 0) headers.push({ key: '', value: '' })
const data: McpServerFormData = {
name: server.name || '',
transport: (server.transport as McpTransport) || 'streamable-http',
url: server.url || '',
timeout: 30000,
headers,
}
setEditFormData(data)
setEditOriginalData(JSON.parse(JSON.stringify(data)))
setShowEditModal(true)
setEditSaveError(null)
clearEditTestResult()
resetEditEnvVarState()
setEditUrlScrollLeft(0)
setEditHeaderScrollLeft({})
},
[clearEditTestResult, resetEditEnvVarState]
)
/**
* Closes the edit modal and resets state.
*/
const handleCloseEditModal = useCallback(() => {
setShowEditModal(false)
setEditFormData(DEFAULT_FORM_DATA)
setEditOriginalData(DEFAULT_FORM_DATA)
setEditSaveError(null)
clearEditTestResult()
resetEditEnvVarState()
}, [clearEditTestResult, resetEditEnvVarState])
/**
* Handles environment variable selection in the edit modal.
*/
const handleEditEnvVarSelect = useCallback(
(newValue: string) => {
if (editActiveInputField === 'url') {
setEditFormData((prev) => ({ ...prev, url: newValue }))
} else if (editActiveHeaderIndex !== null) {
const field = editActiveInputField === 'header-key' ? 'key' : 'value'
const processedValue = field === 'key' ? newValue.replace(/[{}]/g, '') : newValue
setEditFormData((prev) => {
const newHeaders = [...(prev.headers || [])]
if (newHeaders[editActiveHeaderIndex]) {
newHeaders[editActiveHeaderIndex] = {
...newHeaders[editActiveHeaderIndex],
[field]: processedValue,
}
}
return { ...prev, headers: newHeaders }
})
}
resetEditEnvVarState()
},
[editActiveInputField, editActiveHeaderIndex, resetEditEnvVarState]
)
/**
* Handles input changes in the edit modal and manages env var dropdown.
*/
const handleEditInputChange = useCallback(
(field: InputFieldType, value: string, headerIndex?: number) => {
const input = document.activeElement as HTMLInputElement
const pos = input?.selectionStart || 0
setEditCursorPosition(pos)
if (editTestResult) {
clearEditTestResult()
}
const envVarTrigger = checkEnvVarTrigger(value, pos)
setEditShowEnvVars(envVarTrigger.show)
setEditEnvSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
if (envVarTrigger.show) {
setEditActiveInputField(field)
setEditActiveHeaderIndex(headerIndex ?? null)
} else {
resetEditEnvVarState()
}
if (field === 'url') {
setEditFormData((prev) => ({ ...prev, url: value }))
} else if (headerIndex !== undefined) {
const headerField = field === 'header-key' ? 'key' : 'value'
setEditFormData((prev) => {
const newHeaders = [...(prev.headers || [])]
if (newHeaders[headerIndex]) {
newHeaders[headerIndex] = { ...newHeaders[headerIndex], [headerField]: value }
}
return { ...prev, headers: newHeaders }
})
}
},
[editTestResult, clearEditTestResult, resetEditEnvVarState]
)
const handleEditHeaderScroll = useCallback((key: string, scrollLeft: number) => {
setEditHeaderScrollLeft((prev) => ({ ...prev, [key]: scrollLeft }))
}, [])
const handleEditAddHeader = useCallback(() => {
setEditFormData((prev) => ({
...prev,
headers: [...(prev.headers || []), { key: '', value: '' }],
}))
}, [])
const handleEditRemoveHeader = useCallback((index: number) => {
setEditFormData((prev) => ({
...prev,
headers: (prev.headers || []).filter((_, i) => i !== index),
}))
}, [])
/**
* Tests the connection with the edit modal's form data.
*/
const handleEditTestConnection = useCallback(async () => {
if (!editFormData.name.trim() || !editFormData.url?.trim()) return
await editTestConnection({
name: editFormData.name,
transport: editFormData.transport,
url: editFormData.url,
headers: headersToRecord(editFormData.headers),
timeout: editFormData.timeout,
workspaceId,
})
}, [editFormData, editTestConnection, workspaceId, headersToRecord])
/**
* Saves the edited MCP server after validating and testing the connection.
*/
const handleSaveEdit = useCallback(async () => {
if (!selectedServerId || !editFormData.name.trim()) return
setEditSaveError(null)
try {
const headersRecord = headersToRecord(editFormData.headers)
const serverConfig = {
name: editFormData.name,
transport: editFormData.transport,
url: editFormData.url,
headers: headersRecord,
timeout: editFormData.timeout,
workspaceId,
}
const connectionResult = await editTestConnection(serverConfig)
if (!connectionResult.success) {
setEditSaveError(connectionResult.error || 'Connection test failed')
return
}
setIsUpdatingServer(true)
const currentServer = servers.find((s) => s.id === selectedServerId)
await updateServerMutation.mutateAsync({
workspaceId,
serverId: selectedServerId,
updates: {
name: editFormData.name.trim(),
transport: editFormData.transport,
url: editFormData.url,
headers: headersRecord,
timeout: editFormData.timeout || 30000,
enabled: currentServer?.enabled ?? true,
},
})
setShowEditModal(false)
logger.info(`Updated MCP server: ${editFormData.name}`)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update server'
setEditSaveError(message)
logger.error('Failed to update MCP server:', error)
} finally {
setIsUpdatingServer(false)
}
}, [
selectedServerId,
editFormData,
editTestConnection,
updateServerMutation,
workspaceId,
headersToRecord,
servers,
])
/**
* Gets the selected server and its tools for the detail view.
*/
@@ -777,6 +1009,26 @@ export function MCP({ initialServerId }: MCPProps) {
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim()
const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection)
const hasEditChanges = useMemo(() => {
if (editFormData.name !== editOriginalData.name) return true
if (editFormData.url !== editOriginalData.url) return true
if (editFormData.transport !== editOriginalData.transport) return true
const currentHeaders = editFormData.headers || []
const originalHeaders = editOriginalData.headers || []
if (currentHeaders.length !== originalHeaders.length) return true
for (let i = 0; i < currentHeaders.length; i++) {
if (
currentHeaders[i].key !== originalHeaders[i].key ||
currentHeaders[i].value !== originalHeaders[i].value
)
return true
}
return false
}, [editFormData, editOriginalData])
/**
* Gets issues for stored tools that reference a specific server tool.
* Returns issues from all workflows that have stored this tool.
@@ -905,7 +1157,6 @@ export function MCP({ initialServerId }: MCPProps) {
<Badge
variant={getIssueBadgeVariant(issues[0].issue)}
size='sm'
className='cursor-help'
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
@@ -991,23 +1242,135 @@ export function MCP({ initialServerId }: MCPProps) {
</div>
<div className='mt-auto flex items-center justify-between'>
<Button
onClick={() => handleRefreshServer(server.id)}
variant='default'
disabled={!!refreshingServers[server.id]}
>
{refreshingServers[server.id]?.status === 'refreshing'
? 'Refreshing...'
: refreshingServers[server.id]?.status === 'refreshed'
? refreshingServers[server.id].workflowsUpdated
? `Synced (${refreshingServers[server.id].workflowsUpdated} workflow${refreshingServers[server.id].workflowsUpdated === 1 ? '' : 's'})`
: 'Refreshed'
: 'Refresh Tools'}
</Button>
<div className='flex items-center gap-[8px]'>
<Button
onClick={() => handleRefreshServer(server.id)}
variant='default'
disabled={!!refreshingServers[server.id]}
>
{refreshingServers[server.id]?.status === 'refreshing'
? 'Refreshing...'
: refreshingServers[server.id]?.status === 'refreshed'
? refreshingServers[server.id].workflowsUpdated
? `Synced (${refreshingServers[server.id].workflowsUpdated} workflow${refreshingServers[server.id].workflowsUpdated === 1 ? '' : 's'})`
: 'Refreshed'
: 'Refresh Tools'}
</Button>
<Button onClick={() => handleOpenEditModal(server)} variant='default'>
Edit
</Button>
</div>
<Button onClick={handleBackToList} variant='tertiary'>
Back
</Button>
</div>
<Modal open={showEditModal} onOpenChange={setShowEditModal}>
<ModalContent>
<ModalHeader>Edit MCP Server</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[8px]'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={editFormData.name}
onChange={(e) => {
if (editTestResult) clearEditTestResult()
setEditFormData((prev) => ({ ...prev, name: e.target.value }))
}}
className='h-9'
/>
</FormField>
<FormField label='Server URL'>
<FormattedInput
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
value={editFormData.url || ''}
scrollLeft={editUrlScrollLeft}
showEnvVars={editShowEnvVars && editActiveInputField === 'url'}
envVarProps={{
searchTerm: editEnvSearchTerm,
cursorPosition: editCursorPosition,
workspaceId,
onSelect: handleEditEnvVarSelect,
onClose: resetEditEnvVarState,
}}
availableEnvVars={availableEnvVars}
onChange={(e) => handleEditInputChange('url', e.target.value)}
onScroll={setEditUrlScrollLeft}
/>
</FormField>
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Headers
</span>
<Button
type='button'
variant='ghost'
onClick={handleEditAddHeader}
className='h-6 w-6 p-0'
>
<Plus className='h-3 w-3' />
</Button>
</div>
<div className='flex max-h-[140px] flex-col gap-[8px] overflow-y-auto'>
{(editFormData.headers || []).map((header, index) => (
<HeaderRow
key={index}
header={header}
index={index}
headerScrollLeft={editHeaderScrollLeft}
showEnvVars={editShowEnvVars}
activeInputField={editActiveInputField}
activeHeaderIndex={editActiveHeaderIndex}
envSearchTerm={editEnvSearchTerm}
cursorPosition={editCursorPosition}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
onInputChange={handleEditInputChange}
onHeaderScroll={handleEditHeaderScroll}
onEnvVarSelect={handleEditEnvVarSelect}
onEnvVarClose={resetEditEnvVarState}
onRemove={() => handleEditRemoveHeader(index)}
/>
))}
</div>
</div>
</div>
</ModalBody>
<ModalFooter>
{editSaveError && (
<p className='mb-[8px] w-full text-[12px] text-[var(--text-error)]'>
{editSaveError}
</p>
)}
<div className='flex w-full items-center justify-between'>
<Button
variant='default'
onClick={handleEditTestConnection}
disabled={isEditTestingConnection || !isEditFormValid}
>
{editTestButtonLabel}
</Button>
<div className='flex items-center gap-[8px]'>
<Button variant='ghost' onClick={handleCloseEditModal}>
Cancel
</Button>
<Button
onClick={handleSaveEdit}
disabled={!hasEditChanges || isUpdatingServer || !isEditFormValid}
variant='tertiary'
>
{isUpdatingServer ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import type { ChangeEvent } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -52,21 +52,17 @@ export function SkillModal({
const [content, setContent] = useState('')
const [errors, setErrors] = useState<FieldErrors>({})
const [saving, setSaving] = useState(false)
const [prevOpen, setPrevOpen] = useState(false)
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
useEffect(() => {
if (open) {
if (initialValues) {
setName(initialValues.name)
setDescription(initialValues.description)
setContent(initialValues.content)
} else {
setName('')
setDescription('')
setContent('')
}
setErrors({})
}
}, [open, initialValues])
if ((open && !prevOpen) || (open && initialValues !== prevInitialValues)) {
setName(initialValues?.name ?? '')
setDescription(initialValues?.description ?? '')
setContent(initialValues?.content ?? '')
setErrors({})
}
if (open !== prevOpen) setPrevOpen(open)
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
const hasChanges = useMemo(() => {
if (!initialValues) return true

View File

@@ -4,17 +4,8 @@ import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { SkillModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal'
import type { SkillDefinition } from '@/hooks/queries/skills'
import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
@@ -105,12 +96,7 @@ export function Skills() {
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='flex items-center gap-[8px]'>
<div
className={cn(
'flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]',
isLoading && 'opacity-50'
)}
>
<div className='flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
@@ -120,7 +106,7 @@ export function Skills() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={isLoading}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>

View File

@@ -1,17 +1,18 @@
'use client'
import React, { useState } from 'react'
import React from 'react'
import { ChevronDown } from 'lucide-react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
Checkbox,
Input,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
TagInput,
type TagItem,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -64,8 +65,8 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
PermissionSelector.displayName = 'PermissionSelector'
interface MemberInvitationCardProps {
inviteEmail: string
setInviteEmail: (email: string) => void
inviteEmails: TagItem[]
setInviteEmails: (emails: TagItem[]) => void
isInviting: boolean
showWorkspaceInvite: boolean
setShowWorkspaceInvite: (show: boolean) => void
@@ -82,8 +83,8 @@ interface MemberInvitationCardProps {
}
export function MemberInvitationCard({
inviteEmail,
setInviteEmail,
inviteEmails,
setInviteEmails,
isInviting,
showWorkspaceInvite,
setShowWorkspaceInvite,
@@ -100,45 +101,26 @@ export function MemberInvitationCard({
}: MemberInvitationCardProps) {
const selectedCount = selectedWorkspaces.length
const hasAvailableSeats = availableSeats > 0
const [emailError, setEmailError] = useState<string>('')
const hasValidEmails = inviteEmails.some((e) => e.isValid)
const validateEmailInput = (email: string) => {
if (!email.trim()) {
setEmailError('')
return
}
const handleAddEmail = (value: string) => {
const normalized = value.trim().toLowerCase()
if (!normalized) return false
const validation = quickValidateEmail(email.trim())
if (!validation.isValid) {
setEmailError(validation.reason || 'Please enter a valid email address')
} else {
setEmailError('')
}
const isDuplicate = inviteEmails.some((e) => e.value === normalized)
if (isDuplicate) return false
const validation = quickValidateEmail(normalized)
setInviteEmails([...inviteEmails, { value: normalized, isValid: validation.isValid }])
return validation.isValid
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInviteEmail(value)
if (emailError) {
setEmailError('')
}
}
const handleInviteClick = () => {
if (inviteEmail.trim()) {
validateEmailInput(inviteEmail)
const validation = quickValidateEmail(inviteEmail.trim())
if (!validation.isValid) {
return // Don't proceed if validation fails
}
}
onInviteMember()
const handleRemoveEmail = (_value: string, index: number) => {
setInviteEmails(inviteEmails.filter((_, i) => i !== index))
}
return (
<div className='overflow-hidden rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
{/* Header */}
<div className='px-[14px] py-[10px]'>
<h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Invite Team Members</h4>
<p className='text-[12px] text-[var(--text-muted)]'>
@@ -147,46 +129,18 @@ export function MemberInvitationCard({
</div>
<div className='flex flex-col gap-[12px] border-[var(--border-1)] border-t bg-[var(--surface-4)] px-[14px] py-[12px]'>
{/* Main invitation input */}
<div className='flex items-start gap-[8px]'>
<div className='flex-1'>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<Input
placeholder='Enter email address'
value={inviteEmail}
onChange={handleEmailChange}
<TagInput
items={inviteEmails}
onAdd={handleAddEmail}
onRemove={handleRemoveEmail}
placeholder='Enter email addresses'
placeholderWithTags='Add another email'
disabled={isInviting || !hasAvailableSeats}
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
name='member_invite_field'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
data-lpignore='true'
data-form-type='other'
aria-autocomplete='none'
triggerKeys={['Enter', ',', ' ']}
maxHeight='max-h-24'
/>
{emailError && (
<p className='mt-1 text-[12px] text-[var(--text-error)] leading-tight'>
{emailError}
</p>
)}
</div>
<Popover
open={showWorkspaceInvite}
@@ -220,8 +174,9 @@ export function MemberInvitationCard({
align='end'
maxHeight={320}
sideOffset={4}
className='w-[240px] border border-[var(--border-muted)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
style={{ minWidth: '240px', maxWidth: '240px' }}
minWidth={240}
maxWidth={240}
border
>
{isLoadingWorkspaces ? (
<div className='px-[6px] py-[16px] text-center'>
@@ -286,14 +241,13 @@ export function MemberInvitationCard({
</Popover>
<Button
variant='tertiary'
onClick={handleInviteClick}
disabled={!inviteEmail || isInviting || !hasAvailableSeats}
onClick={() => onInviteMember()}
disabled={!hasValidEmails || isInviting || !hasAvailableSeats}
>
{isInviting ? 'Inviting...' : hasAvailableSeats ? 'Invite' : 'No Seats'}
</Button>
</div>
{/* Invitation error - inline */}
{invitationError && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
{invitationError instanceof Error && invitationError.message
@@ -302,7 +256,6 @@ export function MemberInvitationCard({
</p>
)}
{/* Success message */}
{inviteSuccess && (
<p className='text-[11px] text-[var(--text-success)] leading-tight'>
Invitation sent successfully

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { TagItem } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
@@ -69,7 +70,7 @@ export function TeamManagement() {
const [inviteSuccess, setInviteSuccess] = useState(false)
const [inviteEmail, setInviteEmail] = useState('')
const [inviteEmails, setInviteEmails] = useState<TagItem[]>([])
const [showWorkspaceInvite, setShowWorkspaceInvite] = useState(false)
const [selectedWorkspaces, setSelectedWorkspaces] = useState<
Array<{ workspaceId: string; permission: string }>
@@ -129,7 +130,8 @@ export function TeamManagement() {
}, [orgName, orgSlug, createOrgMutation])
const handleInviteMember = useCallback(async () => {
if (!session?.user || !activeOrganization?.id || !inviteEmail.trim()) return
const validEmails = inviteEmails.filter((e) => e.isValid).map((e) => e.value)
if (!session?.user || !activeOrganization?.id || validEmails.length === 0) return
try {
const workspaceInvitations =
@@ -141,23 +143,21 @@ export function TeamManagement() {
: undefined
await inviteMutation.mutateAsync({
email: inviteEmail.trim(),
emails: validEmails,
orgId: activeOrganization.id,
workspaceInvitations,
})
// Show success state
setInviteSuccess(true)
setTimeout(() => setInviteSuccess(false), 3000)
// Reset form
setInviteEmail('')
setInviteEmails([])
setSelectedWorkspaces([])
setShowWorkspaceInvite(false)
} catch (error) {
logger.error('Failed to invite member', error)
}
}, [session?.user?.id, activeOrganization?.id, inviteEmail, selectedWorkspaces, inviteMutation])
}, [session?.user?.id, activeOrganization?.id, inviteEmails, selectedWorkspaces, inviteMutation])
const handleWorkspaceToggle = useCallback((workspaceId: string, permission: string) => {
setSelectedWorkspaces((prev) => {
@@ -391,15 +391,15 @@ export function TeamManagement() {
{adminOrOwner && !isInvitationsDisabled && (
<div>
<MemberInvitationCard
inviteEmail={inviteEmail}
setInviteEmail={setInviteEmail}
inviteEmails={inviteEmails}
setInviteEmails={setInviteEmails}
isInviting={inviteMutation.isPending}
showWorkspaceInvite={showWorkspaceInvite}
setShowWorkspaceInvite={setShowWorkspaceInvite}
selectedWorkspaces={selectedWorkspaces}
userWorkspaces={adminWorkspaces}
onInviteMember={handleInviteMember}
onLoadUserWorkspaces={async () => {}} // No-op: data is auto-loaded by React Query
onLoadUserWorkspaces={async () => {}}
onWorkspaceToggle={handleWorkspaceToggle}
inviteSuccess={inviteSuccess}
availableSeats={Math.max(0, totalSeats - usedSeats.used)}