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

@@ -44,6 +44,7 @@ Retrieve detailed information about a specific Jira issue
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `issueKey` | string | Yes | Jira issue key to retrieve \(e.g., PROJ-123\) |
| `includeAttachments` | boolean | No | Download attachment file contents and include them as files in the output |
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -65,6 +66,7 @@ Retrieve detailed information about a specific Jira issue
| ↳ `key` | string | Status category key \(e.g., new, indeterminate, done\) |
| ↳ `name` | string | Status category name \(e.g., To Do, In Progress, Done\) |
| ↳ `colorName` | string | Status category color \(e.g., blue-gray, yellow, green\) |
| `statusName` | string | Issue status name \(e.g., Open, In Progress, Done\) |
| `issuetype` | object | Issue type |
| ↳ `id` | string | Issue type ID |
| ↳ `name` | string | Issue type name \(e.g., Task, Bug, Story, Epic\) |
@@ -88,6 +90,7 @@ Retrieve detailed information about a specific Jira issue
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| `assigneeName` | string | Assignee display name or account ID |
| `reporter` | object | Reporter user |
| ↳ `accountId` | string | Atlassian account ID of the user |
| ↳ `displayName` | string | Display name of the user |
@@ -173,6 +176,7 @@ Retrieve detailed information about a specific Jira issue
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| ↳ `authorName` | string | Comment author display name |
| ↳ `updateAuthor` | object | User who last updated the comment |
| ↳ `accountId` | string | Atlassian account ID of the user |
| ↳ `displayName` | string | Display name of the user |
@@ -196,6 +200,7 @@ Retrieve detailed information about a specific Jira issue
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| ↳ `authorName` | string | Worklog author display name |
| ↳ `updateAuthor` | object | User who last updated the worklog |
| ↳ `accountId` | string | Atlassian account ID of the user |
| ↳ `displayName` | string | Display name of the user |
@@ -225,9 +230,11 @@ Retrieve detailed information about a specific Jira issue
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| ↳ `authorName` | string | Attachment author display name |
| ↳ `created` | string | ISO 8601 timestamp when the attachment was created |
| `issueKey` | string | Issue key \(e.g., PROJ-123\) |
| `issue` | json | Complete raw Jira issue object from the API |
| `files` | file[] | Downloaded attachment files \(only when includeAttachments is true\) |
### `jira_update`
@@ -258,6 +265,7 @@ Update a Jira issue
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Updated issue key \(e.g., PROJ-123\) |
| `summary` | string | Issue summary after update |
@@ -296,6 +304,7 @@ Create a new Jira issue
| `issueKey` | string | Created issue key \(e.g., PROJ-123\) |
| `self` | string | REST API URL for the created issue |
| `summary` | string | Issue summary |
| `success` | boolean | Whether the issue was created successfully |
| `url` | string | URL to the created issue in Jira |
| `assigneeId` | string | Account ID of the assigned user \(null if no assignee was set\) |
@@ -358,6 +367,7 @@ Delete a Jira issue
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Deleted issue key |
### `jira_assign_issue`
@@ -378,6 +388,7 @@ Assign a Jira issue to a user
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Issue key that was assigned |
| `assigneeId` | string | Account ID of the assignee \(use "-1" for auto-assign, null to unassign\) |
@@ -401,6 +412,7 @@ Move a Jira issue between workflow statuses (e.g., To Do -> In Progress)
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Issue key that was transitioned |
| `transitionId` | string | Applied transition ID |
| `transitionName` | string | Applied transition name |
@@ -443,6 +455,7 @@ Search for Jira issues using JQL (Jira Query Language)
| ↳ `key` | string | Status category key \(e.g., new, indeterminate, done\) |
| ↳ `name` | string | Status category name \(e.g., To Do, In Progress, Done\) |
| ↳ `colorName` | string | Status category color \(e.g., blue-gray, yellow, green\) |
| ↳ `statusName` | string | Issue status name \(e.g., Open, In Progress, Done\) |
| ↳ `issuetype` | object | Issue type |
| ↳ `id` | string | Issue type ID |
| ↳ `name` | string | Issue type name \(e.g., Task, Bug, Story, Epic\) |
@@ -466,6 +479,7 @@ Search for Jira issues using JQL (Jira Query Language)
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| ↳ `assigneeName` | string | Assignee display name or account ID |
| ↳ `reporter` | object | Reporter user |
| ↳ `accountId` | string | Atlassian account ID of the user |
| ↳ `displayName` | string | Display name of the user |
@@ -509,6 +523,7 @@ Add a comment to a Jira issue
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Issue key the comment was added to |
| `commentId` | string | Created comment ID |
| `body` | string | Comment text content |
@@ -558,6 +573,7 @@ Get all comments from a Jira issue
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| ↳ `authorName` | string | Comment author display name |
| ↳ `updateAuthor` | object | User who last updated the comment |
| ↳ `accountId` | string | Atlassian account ID of the user |
| ↳ `displayName` | string | Display name of the user |
@@ -592,6 +608,7 @@ Update an existing comment on a Jira issue
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Issue key |
| `commentId` | string | Updated comment ID |
| `body` | string | Updated comment text |
@@ -624,6 +641,7 @@ Delete a comment from a Jira issue
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Issue key |
| `commentId` | string | Deleted comment ID |
@@ -637,6 +655,7 @@ Get all attachments from a Jira issue
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `issueKey` | string | Yes | Jira issue key to get attachments from \(e.g., PROJ-123\) |
| `includeAttachments` | boolean | No | Download attachment file contents and include them as files in the output |
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -660,7 +679,9 @@ Get all attachments from a Jira issue
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| ↳ `authorName` | string | Attachment author display name |
| ↳ `created` | string | ISO 8601 timestamp when the attachment was created |
| `files` | file[] | Downloaded attachment files \(only when includeAttachments is true\) |
### `jira_add_attachment`
@@ -688,10 +709,7 @@ Add attachments to a Jira issue
| ↳ `size` | number | File size in bytes |
| ↳ `content` | string | URL to download the attachment |
| `attachmentIds` | array | Array of attachment IDs |
| `files` | array | Uploaded file metadata |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `size` | number | File size in bytes |
| `files` | file[] | Uploaded attachment files |
### `jira_delete_attachment`
@@ -710,6 +728,7 @@ Delete an attachment from a Jira issue
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `attachmentId` | string | Deleted attachment ID |
### `jira_add_worklog`
@@ -733,6 +752,7 @@ Add a time tracking worklog entry to a Jira issue
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Issue key the worklog was added to |
| `worklogId` | string | Created worklog ID |
| `timeSpent` | string | Time spent in human-readable format \(e.g., 3h 20m\) |
@@ -781,6 +801,7 @@ Get all worklog entries from a Jira issue
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| ↳ `authorName` | string | Worklog author display name |
| ↳ `updateAuthor` | object | User who last updated the worklog |
| ↳ `accountId` | string | Atlassian account ID of the user |
| ↳ `displayName` | string | Display name of the user |
@@ -818,6 +839,7 @@ Update an existing worklog entry on a Jira issue
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Issue key |
| `worklogId` | string | Updated worklog ID |
| `timeSpent` | string | Human-readable time spent \(e.g., "3h 20m"\) |
@@ -861,6 +883,7 @@ Delete a worklog entry from a Jira issue
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Issue key |
| `worklogId` | string | Deleted worklog ID |
@@ -884,6 +907,7 @@ Create a link relationship between two Jira issues
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `inwardIssue` | string | Inward issue key |
| `outwardIssue` | string | Outward issue key |
| `linkType` | string | Type of issue link |
@@ -906,6 +930,7 @@ Delete a link between two Jira issues
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `linkId` | string | Deleted link ID |
### `jira_add_watcher`
@@ -926,6 +951,7 @@ Add a watcher to a Jira issue to receive notifications about updates
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Issue key |
| `watcherAccountId` | string | Added watcher account ID |
@@ -947,6 +973,7 @@ Remove a watcher from a Jira issue
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `issueKey` | string | Issue key |
| `watcherAccountId` | string | Removed watcher account ID |
@@ -977,6 +1004,8 @@ Get Jira users. If an account ID is provided, returns a single user. Otherwise,
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| ↳ `avatarUrls` | json | User avatar URLs in multiple sizes \(16x16, 24x24, 32x32, 48x48\) |
| ↳ `self` | string | REST API URL for this user |
| `total` | number | Total number of users returned |
| `startAt` | number | Pagination start index |
| `maxResults` | number | Maximum results per page |

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)}

View File

@@ -44,7 +44,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
id: 'domain',
title: 'Domain',
type: 'short-input',
placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)',
placeholder: 'Enter Confluence domain (e.g., company.atlassian.net)',
required: true,
},
{
@@ -462,7 +462,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
id: 'domain',
title: 'Domain',
type: 'short-input',
placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)',
placeholder: 'Enter Confluence domain (e.g., company.atlassian.net)',
required: true,
},
{

View File

@@ -54,7 +54,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
title: 'Domain',
type: 'short-input',
required: true,
placeholder: 'Enter Jira domain (e.g., simstudio.atlassian.net)',
placeholder: 'Enter Jira domain (e.g., company.atlassian.net)',
},
{
id: 'credential',

View File

@@ -49,7 +49,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
title: 'Domain',
type: 'short-input',
required: true,
placeholder: 'Enter Jira domain (e.g., simstudio.atlassian.net)',
placeholder: 'Enter Jira domain (e.g., company.atlassian.net)',
},
{
id: 'credential',

View File

@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
containsUserFileWithMetadata,
@@ -376,6 +377,7 @@ export class BlockExecutor {
* - Filters out system fields (UI-only, readonly, internal flags)
* - Removes UI state from inputFormat items (e.g., collapsed)
* - Parses JSON strings to objects for readability
* - Redacts sensitive fields (privateKey, password, tokens, etc.)
* Returns a new object - does not mutate the original inputs.
*/
private sanitizeInputsForLog(inputs: Record<string, any>): Record<string, any> {
@@ -410,7 +412,7 @@ export class BlockExecutor {
}
}
return result
return redactApiKeys(result)
}
private callOnBlockStart(

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/shared'
import type { McpServerStatusConfig, McpTool, StoredMcpTool } from '@/lib/mcp/types'
import { workflowMcpServerKeys } from '@/hooks/queries/workflow-mcp-servers'
const logger = createLogger('McpQueries')
@@ -378,6 +379,8 @@ const sseConnections: Map<string, SseEntry> =
* Subscribe to MCP tool-change SSE events for a workspace.
* On each `tools_changed` event, invalidates the relevant React Query caches
* so the UI refreshes automatically.
*
* Invalidates both external MCP server keys and workflow MCP server keys.
*/
export function useMcpToolsEvents(workspaceId: string) {
const queryClient = useQueryClient()
@@ -389,6 +392,7 @@ export function useMcpToolsEvents(workspaceId: string) {
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(workspaceId) })
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(workspaceId) })
queryClient.invalidateQueries({ queryKey: mcpKeys.storedTools(workspaceId) })
queryClient.invalidateQueries({ queryKey: workflowMcpServerKeys.all })
}
let entry = sseConnections.get(workspaceId)

View File

@@ -255,7 +255,7 @@ export function useUpdateOrganizationUsageLimit() {
* Invite member mutation
*/
interface InviteMemberParams {
email: string
emails: string[]
workspaceInvitations?: Array<{ workspaceId: string; permission: 'admin' | 'write' | 'read' }>
orgId: string
}
@@ -264,12 +264,12 @@ export function useInviteMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ email, workspaceInvitations, orgId }: InviteMemberParams) => {
mutationFn: async ({ emails, workspaceInvitations, orgId }: InviteMemberParams) => {
const response = await fetch(`/api/organizations/${orgId}/invitations?batch=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
emails: [email],
emails,
workspaceInvitations,
}),
})

View File

@@ -4,6 +4,8 @@ import { chat, workflowMcpTool } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/persistence/utils'
import { checkChatAccess, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
@@ -245,7 +247,10 @@ export async function executeDeployMcp(
params.toolDescription ||
workflowRecord.description ||
`Execute ${workflowRecord.name} workflow`
const parameterSchema = params.parameterSchema || {}
const parameterSchema =
params.parameterSchema && Object.keys(params.parameterSchema).length > 0
? params.parameterSchema
: await generateParameterSchemaForWorkflow(workflowId)
const baseUrl = getBaseUrl()
const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}`
@@ -261,6 +266,9 @@ export async function executeDeployMcp(
updatedAt: new Date(),
})
.where(eq(workflowMcpTool.id, toolId))
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
return {
success: true,
output: { toolId, toolName, toolDescription, updated: true, mcpServerUrl, baseUrl },
@@ -279,6 +287,8 @@ export async function executeDeployMcp(
updatedAt: new Date(),
})
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
return {
success: true,
output: { toolId, toolName, toolDescription, updated: false, mcpServerUrl, baseUrl },

View File

@@ -3,6 +3,8 @@ import { db } from '@sim/db'
import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { eq, inArray } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { mcpPubSub } from '@/lib/mcp/pubsub'
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'
import { ensureWorkflowAccess } from '../access'
@@ -205,13 +207,14 @@ export async function executeCreateWorkspaceMcpServer(
continue
}
const toolName = sanitizeToolName(wf.name || `workflow_${wf.id}`)
const parameterSchema = await generateParameterSchemaForWorkflow(wf.id)
await db.insert(workflowMcpTool).values({
id: crypto.randomUUID(),
serverId,
workflowId: wf.id,
toolName,
toolDescription: wf.description || `Execute ${wf.name} workflow`,
parameterSchema: {},
parameterSchema,
createdAt: new Date(),
updatedAt: new Date(),
})
@@ -219,6 +222,10 @@ export async function executeCreateWorkspaceMcpServer(
}
}
if (addedTools.length > 0) {
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
}
return { success: true, output: { server, addedTools } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }

View File

@@ -1,24 +1,44 @@
import { db, workflowMcpTool } from '@sim/db'
import { db, workflowMcpServer, workflowMcpTool } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { eq, inArray } from 'drizzle-orm'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { mcpPubSub } from './pubsub'
import { extractInputFormatFromBlocks, generateToolInputSchema } from './workflow-tool-schema'
const logger = createLogger('WorkflowMcpSync')
const EMPTY_SCHEMA: Record<string, unknown> = Object.freeze({ type: 'object', properties: {} })
/**
* Generate MCP tool parameter schema from workflow blocks
* Generate MCP tool parameter schema from workflow blocks.
*/
function generateSchemaFromBlocks(blocks: Record<string, unknown>): Record<string, unknown> {
export function generateSchemaFromBlocks(blocks: Record<string, unknown>): Record<string, unknown> {
const inputFormat = extractInputFormatFromBlocks(blocks)
if (!inputFormat || inputFormat.length === 0) {
return { type: 'object', properties: {} }
return EMPTY_SCHEMA
}
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
}
/**
* Load a workflow's active deployed state and generate its MCP parameter schema.
* Returns a proper JSON Schema derived from the start block's input format,
* or a fallback empty schema if the workflow has no inputs or no active deployment.
*/
export async function generateParameterSchemaForWorkflow(
workflowId: string
): Promise<Record<string, unknown>> {
try {
const deployed = await loadDeployedWorkflowState(workflowId)
if (!deployed?.blocks) return EMPTY_SCHEMA
return generateSchemaFromBlocks(deployed.blocks as Record<string, unknown>)
} catch {
return EMPTY_SCHEMA
}
}
interface SyncOptions {
workflowId: string
requestId: string
@@ -42,9 +62,8 @@ export async function syncMcpToolsForWorkflow(options: SyncOptions): Promise<voi
const { workflowId, requestId, state, context = 'sync' } = options
try {
// Get all MCP tools that use this workflow
const tools = await db
.select({ id: workflowMcpTool.id })
.select({ id: workflowMcpTool.id, serverId: workflowMcpTool.serverId })
.from(workflowMcpTool)
.where(eq(workflowMcpTool.workflowId, workflowId))
@@ -53,25 +72,23 @@ export async function syncMcpToolsForWorkflow(options: SyncOptions): Promise<voi
return
}
// Get workflow state (from param or load from DB)
let workflowState: { blocks?: Record<string, unknown> } | null = state ?? null
if (!workflowState) {
workflowState = await loadWorkflowFromNormalizedTables(workflowId)
workflowState = await loadDeployedWorkflowState(workflowId)
}
// Check if workflow has a valid start block
if (!hasValidStartBlockInState(workflowState as WorkflowState | null)) {
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(
`[${requestId}] Removed ${tools.length} MCP tool(s) - workflow has no start block (${context}): ${workflowId}`
)
notifyAffectedServers(tools)
return
}
// Generate and update parameter schema
const parameterSchema = workflowState?.blocks
? generateSchemaFromBlocks(workflowState.blocks)
: { type: 'object', properties: {} }
: EMPTY_SCHEMA
await db
.update(workflowMcpTool)
@@ -84,24 +101,62 @@ export async function syncMcpToolsForWorkflow(options: SyncOptions): Promise<voi
logger.info(
`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow (${context}): ${workflowId}`
)
notifyAffectedServers(tools)
} catch (error) {
logger.error(`[${requestId}] Error syncing MCP tools (${context}):`, error)
// Don't throw - this is a non-critical operation
}
}
/**
* Remove all MCP tools for a workflow (used when undeploying)
* Remove all MCP tools for a workflow (used when undeploying).
* Queries affected tools before deleting so we can notify their servers.
*/
export async function removeMcpToolsForWorkflow(
workflowId: string,
requestId: string
): Promise<void> {
try {
const tools = await db
.select({ id: workflowMcpTool.id, serverId: workflowMcpTool.serverId })
.from(workflowMcpTool)
.where(eq(workflowMcpTool.workflowId, workflowId))
if (tools.length === 0) return
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(`[${requestId}] Removed MCP tools for workflow: ${workflowId}`)
notifyAffectedServers(tools)
} catch (error) {
logger.error(`[${requestId}] Error removing MCP tools:`, error)
// Don't throw - this is a non-critical operation
}
}
/**
* Publish pubsub events for each unique server affected by a tool change.
* Resolves workspace IDs from the server table so callers don't need to pass them.
*/
function notifyAffectedServers(tools: Array<{ serverId: string }>): void {
if (!mcpPubSub) return
const uniqueServerIds = [...new Set(tools.map((t) => t.serverId))]
void (async () => {
try {
const servers = await db
.select({ id: workflowMcpServer.id, workspaceId: workflowMcpServer.workspaceId })
.from(workflowMcpServer)
.where(inArray(workflowMcpServer.id, uniqueServerIds))
for (const server of servers) {
mcpPubSub.publishWorkflowToolsChanged({
serverId: server.id,
workspaceId: server.workspaceId,
})
}
} catch (error) {
logger.error('Error notifying affected servers:', error)
}
})()
}

View File

@@ -98,18 +98,6 @@ export const jiraAddAttachmentTool: ToolConfig<JiraAddAttachmentParams, JiraAddA
items: { type: 'string' },
optional: true,
},
files: {
type: 'array',
description: 'Uploaded file metadata',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'File name' },
mimeType: { type: 'string', description: 'MIME type' },
size: { type: 'number', description: 'File size in bytes' },
},
},
optional: true,
},
files: { type: 'file[]', description: 'Uploaded attachment files' },
},
}

View File

@@ -1,5 +1,5 @@
import type { JiraAddCommentParams, JiraAddCommentResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types'
import { extractAdfText, getJiraCloudId, transformUser } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -169,6 +169,7 @@ export const jiraAddCommentTool: ToolConfig<JiraAddCommentParams, JiraAddComment
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Issue key the comment was added to' },
commentId: { type: 'string', description: 'Created comment ID' },
body: { type: 'string', description: 'Comment text content' },

View File

@@ -1,5 +1,5 @@
import type { JiraAddWatcherParams, JiraAddWatcherResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -125,6 +125,7 @@ export const jiraAddWatcherTool: ToolConfig<JiraAddWatcherParams, JiraAddWatcher
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Issue key' },
watcherAccountId: { type: 'string', description: 'Added watcher account ID' },
},

View File

@@ -1,5 +1,5 @@
import type { JiraAddWorklogParams, JiraAddWorklogResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types'
import { getJiraCloudId, transformUser } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -185,6 +185,7 @@ export const jiraAddWorklogTool: ToolConfig<JiraAddWorklogParams, JiraAddWorklog
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Issue key the worklog was added to' },
worklogId: { type: 'string', description: 'Created worklog ID' },
timeSpent: {

View File

@@ -1,5 +1,5 @@
import type { JiraAssignIssueParams, JiraAssignIssueResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -130,6 +130,7 @@ export const jiraAssignIssueTool: ToolConfig<JiraAssignIssueParams, JiraAssignIs
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Issue key that was assigned' },
assigneeId: {
type: 'string',

View File

@@ -1,5 +1,5 @@
import type { JiraCreateIssueLinkParams, JiraCreateIssueLinkResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -184,9 +184,10 @@ export const jiraCreateIssueLinkTool: ToolConfig<
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
inwardIssue: { type: 'string', description: 'Inward issue key' },
outwardIssue: { type: 'string', description: 'Outward issue key' },
linkType: { type: 'string', description: 'Type of issue link' },
linkId: { type: 'string', description: 'Created link ID' },
linkId: { type: 'string', description: 'Created link ID', optional: true },
},
}

View File

@@ -1,5 +1,5 @@
import type { JiraDeleteAttachmentParams, JiraDeleteAttachmentResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -115,6 +115,7 @@ export const jiraDeleteAttachmentTool: ToolConfig<
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
attachmentId: { type: 'string', description: 'Deleted attachment ID' },
},
}

View File

@@ -1,5 +1,5 @@
import type { JiraDeleteCommentParams, JiraDeleteCommentResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -121,6 +121,7 @@ export const jiraDeleteCommentTool: ToolConfig<JiraDeleteCommentParams, JiraDele
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Issue key' },
commentId: { type: 'string', description: 'Deleted comment ID' },
},

View File

@@ -1,5 +1,5 @@
import type { JiraDeleteIssueParams, JiraDeleteIssueResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -157,6 +157,7 @@ export const jiraDeleteIssueTool: ToolConfig<JiraDeleteIssueParams, JiraDeleteIs
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Deleted issue key' },
},
}

View File

@@ -1,5 +1,5 @@
import type { JiraDeleteIssueLinkParams, JiraDeleteIssueLinkResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -113,6 +113,7 @@ export const jiraDeleteIssueLinkTool: ToolConfig<
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
linkId: { type: 'string', description: 'Deleted link ID' },
},
}

View File

@@ -1,5 +1,5 @@
import type { JiraDeleteWorklogParams, JiraDeleteWorklogResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -119,6 +119,7 @@ export const jiraDeleteWorklogTool: ToolConfig<JiraDeleteWorklogParams, JiraDele
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Issue key' },
worklogId: { type: 'string', description: 'Deleted worklog ID' },
},

View File

@@ -1,6 +1,6 @@
import type { JiraGetAttachmentsParams, JiraGetAttachmentsResponse } from '@/tools/jira/types'
import { ATTACHMENT_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId, transformUser } from '@/tools/jira/utils'
import { downloadJiraAttachments, getJiraCloudId, transformUser } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
/**
@@ -15,6 +15,7 @@ function transformAttachment(att: any) {
content: att.content ?? '',
thumbnail: att.thumbnail ?? null,
author: transformUser(att.author),
authorName: att.author?.displayName ?? att.author?.accountId ?? 'Unknown',
created:
typeof att.created === 'number' ? new Date(att.created).toISOString() : (att.created ?? ''),
}
@@ -53,6 +54,12 @@ export const jiraGetAttachmentsTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Jira issue key to get attachments from (e.g., PROJ-123)',
},
includeAttachments: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Download attachment file contents and include them as files in the output',
},
cloudId: {
type: 'string',
required: false,
@@ -118,12 +125,20 @@ export const jiraGetAttachmentsTool: ToolConfig<
data = await response.json()
}
const attachments = (data?.fields?.attachment ?? []).map(transformAttachment)
let files: Array<{ name: string; mimeType: string; data: string; size: number }> | undefined
if (params?.includeAttachments && attachments.length > 0) {
files = await downloadJiraAttachments(attachments, params.accessToken)
}
return {
success: true,
output: {
ts: new Date().toISOString(),
issueKey: params?.issueKey ?? 'unknown',
attachments: (data?.fields?.attachment ?? []).map(transformAttachment),
attachments,
...(files && files.length > 0 ? { files } : {}),
},
}
},
@@ -139,5 +154,10 @@ export const jiraGetAttachmentsTool: ToolConfig<
properties: ATTACHMENT_ITEM_PROPERTIES,
},
},
files: {
type: 'file[]',
description: 'Downloaded attachment files (only when includeAttachments is true)',
optional: true,
},
},
}

View File

@@ -11,6 +11,7 @@ function transformComment(comment: any) {
id: comment.id ?? '',
body: extractAdfText(comment.body) ?? '',
author: transformUser(comment.author) ?? { accountId: '', displayName: '' },
authorName: comment.author?.displayName ?? comment.author?.accountId ?? 'Unknown',
updateAuthor: transformUser(comment.updateAuthor),
created: comment.created ?? '',
updated: comment.updated ?? '',

View File

@@ -14,7 +14,9 @@ function transformUserOutput(user: any) {
displayName: user.displayName ?? '',
emailAddress: user.emailAddress ?? null,
avatarUrl: user.avatarUrls?.['48x48'] ?? null,
avatarUrls: user.avatarUrls ?? null,
timeZone: user.timeZone ?? null,
self: user.self ?? null,
}
}
@@ -165,7 +167,19 @@ export const jiraGetUsersTool: ToolConfig<JiraGetUsersParams, JiraGetUsersRespon
description: 'Array of Jira users',
items: {
type: 'object',
properties: USER_OUTPUT_PROPERTIES,
properties: {
...USER_OUTPUT_PROPERTIES,
avatarUrls: {
type: 'json',
description: 'User avatar URLs in multiple sizes (16x16, 24x24, 32x32, 48x48)',
optional: true,
},
self: {
type: 'string',
description: 'REST API URL for this user',
optional: true,
},
},
},
},
total: { type: 'number', description: 'Total number of users returned' },

View File

@@ -10,6 +10,7 @@ function transformWorklog(worklog: any) {
return {
id: worklog.id ?? '',
author: transformUser(worklog.author) ?? { accountId: '', displayName: '' },
authorName: worklog.author?.displayName ?? worklog.author?.accountId ?? 'Unknown',
updateAuthor: transformUser(worklog.updateAuthor),
comment: worklog.comment ? (extractAdfText(worklog.comment) ?? null) : null,
started: worklog.started ?? '',

View File

@@ -1,5 +1,5 @@
import type { JiraRemoveWatcherParams, JiraRemoveWatcherResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -119,6 +119,7 @@ export const jiraRemoveWatcherTool: ToolConfig<JiraRemoveWatcherParams, JiraRemo
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Issue key' },
watcherAccountId: { type: 'string', description: 'Removed watcher account ID' },
},

View File

@@ -1,7 +1,12 @@
import { createLogger } from '@sim/logger'
import type { JiraRetrieveParams, JiraRetrieveResponse } from '@/tools/jira/types'
import { ISSUE_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { extractAdfText, getJiraCloudId, transformUser } from '@/tools/jira/utils'
import {
downloadJiraAttachments,
extractAdfText,
getJiraCloudId,
transformUser,
} from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('JiraRetrieveTool')
@@ -51,7 +56,9 @@ function transformIssueData(data: any) {
iconUrl: fields.priority.iconUrl ?? null,
}
: null,
statusName: fields.status?.name ?? '',
assignee: transformUser(fields.assignee),
assigneeName: fields.assignee?.displayName ?? fields.assignee?.accountId ?? null,
reporter: transformUser(fields.reporter),
creator: transformUser(fields.creator),
labels: fields.labels ?? [],
@@ -143,13 +150,18 @@ function transformIssueData(data: any) {
id: c.id ?? '',
body: extractAdfText(c.body) ?? '',
author: transformUser(c.author),
authorName: c.author?.displayName ?? c.author?.accountId ?? 'Unknown',
updateAuthor: transformUser(c.updateAuthor),
created: c.created ?? '',
updated: c.updated ?? '',
visibility: c.visibility
? { type: c.visibility.type ?? '', value: c.visibility.value ?? '' }
: null,
})),
worklogs: ((fields.worklog?.worklogs ?? fields.worklog) || []).map((w: any) => ({
id: w.id ?? '',
author: transformUser(w.author),
authorName: w.author?.displayName ?? w.author?.accountId ?? 'Unknown',
updateAuthor: transformUser(w.updateAuthor),
comment: w.comment ? (extractAdfText(w.comment) ?? null) : null,
started: w.started ?? '',
@@ -166,6 +178,7 @@ function transformIssueData(data: any) {
content: att.content ?? '',
thumbnail: att.thumbnail ?? null,
author: transformUser(att.author),
authorName: att.author?.displayName ?? att.author?.accountId ?? 'Unknown',
created: att.created ?? '',
})),
}
@@ -201,6 +214,12 @@ export const jiraRetrieveTool: ToolConfig<JiraRetrieveParams, JiraRetrieveRespon
visibility: 'user-or-llm',
description: 'Jira issue key to retrieve (e.g., PROJ-123)',
},
includeAttachments: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Download attachment file contents and include them as files in the output',
},
cloudId: {
type: 'string',
required: false,
@@ -316,12 +335,20 @@ export const jiraRetrieveTool: ToolConfig<JiraRetrieveParams, JiraRetrieveRespon
await fetchSupplementary(params.cloudId, data)
}
const issueData = transformIssueData(data)
let files: Array<{ name: string; mimeType: string; data: string; size: number }> | undefined
if (params?.includeAttachments && issueData.attachments.length > 0) {
files = await downloadJiraAttachments(issueData.attachments, params.accessToken)
}
return {
success: true,
output: {
ts: new Date().toISOString(),
...transformIssueData(data),
...issueData,
issue: data,
...(files && files.length > 0 ? { files } : {}),
},
}
},
@@ -334,5 +361,10 @@ export const jiraRetrieveTool: ToolConfig<JiraRetrieveParams, JiraRetrieveRespon
description: 'Complete raw Jira issue object from the API',
optional: true,
},
files: {
type: 'file[]',
description: 'Downloaded attachment files (only when includeAttachments is true)',
optional: true,
},
},
}

View File

@@ -47,7 +47,9 @@ function transformSearchIssue(issue: any) {
iconUrl: fields.priority.iconUrl ?? null,
}
: null,
statusName: fields.status?.name ?? '',
assignee: transformUser(fields.assignee),
assigneeName: fields.assignee?.displayName ?? fields.assignee?.accountId ?? null,
reporter: transformUser(fields.reporter),
labels: fields.labels ?? [],
components: (fields.components ?? []).map((c: any) => ({

View File

@@ -1,5 +1,5 @@
import type { JiraTransitionIssueParams, JiraTransitionIssueResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -196,6 +196,7 @@ export const jiraTransitionIssueTool: ToolConfig<
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Issue key that was transitioned' },
transitionId: { type: 'string', description: 'Applied transition ID' },
transitionName: { type: 'string', description: 'Applied transition name', optional: true },

View File

@@ -295,6 +295,7 @@ export const COMMENT_ITEM_PROPERTIES = {
description: 'Comment author',
properties: USER_OUTPUT_PROPERTIES,
},
authorName: { type: 'string', description: 'Comment author display name' },
updateAuthor: {
type: 'object',
description: 'User who last updated the comment',
@@ -356,6 +357,7 @@ export const ATTACHMENT_ITEM_PROPERTIES = {
properties: USER_OUTPUT_PROPERTIES,
optional: true,
},
authorName: { type: 'string', description: 'Attachment author display name' },
created: { type: 'string', description: 'ISO 8601 timestamp when the attachment was created' },
} as const satisfies Record<string, OutputProperty>
@@ -391,6 +393,7 @@ export const WORKLOG_ITEM_PROPERTIES = {
description: 'Worklog author',
properties: USER_OUTPUT_PROPERTIES,
},
authorName: { type: 'string', description: 'Worklog author display name' },
updateAuthor: {
type: 'object',
description: 'User who last updated the worklog',
@@ -470,6 +473,10 @@ export const ISSUE_ITEM_PROPERTIES = {
description: 'Issue status',
properties: STATUS_OUTPUT_PROPERTIES,
},
statusName: {
type: 'string',
description: 'Issue status name (e.g., Open, In Progress, Done)',
},
issuetype: {
type: 'object',
description: 'Issue type',
@@ -492,6 +499,11 @@ export const ISSUE_ITEM_PROPERTIES = {
properties: USER_OUTPUT_PROPERTIES,
optional: true,
},
assigneeName: {
type: 'string',
description: 'Assignee display name or account ID',
optional: true,
},
reporter: {
type: 'object',
description: 'Reporter user',
@@ -658,6 +670,10 @@ export const SEARCH_ISSUE_ITEM_PROPERTIES = {
description: 'Issue status',
properties: STATUS_OUTPUT_PROPERTIES,
},
statusName: {
type: 'string',
description: 'Issue status name (e.g., Open, In Progress, Done)',
},
issuetype: {
type: 'object',
description: 'Issue type',
@@ -680,6 +696,11 @@ export const SEARCH_ISSUE_ITEM_PROPERTIES = {
properties: USER_OUTPUT_PROPERTIES,
optional: true,
},
assigneeName: {
type: 'string',
description: 'Assignee display name or account ID',
optional: true,
},
reporter: {
type: 'object',
description: 'Reporter user',
@@ -735,12 +756,11 @@ export const SUCCESS_OUTPUT: OutputProperty = {
description: 'Operation success status',
}
// --- Parameter interfaces ---
export interface JiraRetrieveParams {
accessToken: string
issueKey: string
domain: string
includeAttachments?: boolean
cloudId?: string
}
@@ -782,24 +802,34 @@ export interface JiraRetrieveResponse extends ToolResponse {
name: string
iconUrl?: string
} | null
statusName: string
assignee: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
assigneeName: string | null
reporter: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
creator: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
labels: string[]
components: Array<{ id: string; name: string; description?: string }>
@@ -836,15 +866,50 @@ export interface JiraRetrieveResponse extends ToolResponse {
comments: Array<{
id: string
body: string
author: { accountId: string; displayName: string } | null
updateAuthor?: { accountId: string; displayName: string } | null
author: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
authorName: string
updateAuthor?: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
created: string
updated: string
visibility: { type: string; value: string } | null
}>
worklogs: Array<{
id: string
author: { accountId: string; displayName: string } | null
updateAuthor?: { accountId: string; displayName: string } | null
author: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
authorName: string
updateAuthor?: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
comment?: string | null
started: string
timeSpent: string
@@ -859,10 +924,20 @@ export interface JiraRetrieveResponse extends ToolResponse {
size: number
content: string
thumbnail?: string | null
author: { accountId: string; displayName: string } | null
author: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
authorName: string
created: string
}>
issue: Record<string, unknown>
files?: Array<{ name: string; mimeType: string; data: string; size: number }>
}
}
@@ -1058,16 +1133,41 @@ export interface JiraSearchIssuesResponse extends ToolResponse {
status: {
id: string
name: string
description?: string
statusCategory?: { id: number; key: string; name: string; colorName: string }
}
issuetype: { id: string; name: string; subtask: boolean }
project: { id: string; key: string; name: string }
priority: { id: string; name: string } | null
assignee: { accountId: string; displayName: string } | null
reporter: { accountId: string; displayName: string } | null
statusName: string
issuetype: {
id: string
name: string
description?: string
subtask: boolean
iconUrl?: string
}
project: { id: string; key: string; name: string; projectTypeKey?: string }
priority: { id: string; name: string; iconUrl?: string } | null
assignee: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
assigneeName: string | null
reporter: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
labels: string[]
components: Array<{ id: string; name: string }>
resolution: { id: string; name: string } | null
components: Array<{ id: string; name: string; description?: string }>
resolution: { id: string; name: string; description?: string } | null
duedate: string | null
created: string
updated: string
@@ -1120,8 +1220,25 @@ export interface JiraGetCommentsResponse extends ToolResponse {
comments: Array<{
id: string
body: string
author: { accountId: string; displayName: string; active?: boolean }
updateAuthor: { accountId: string; displayName: string } | null
author: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
authorName: string
updateAuthor: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
created: string
updated: string
visibility: { type: string; value: string } | null
@@ -1173,6 +1290,7 @@ export interface JiraGetAttachmentsParams {
accessToken: string
domain: string
issueKey: string
includeAttachments?: boolean
cloudId?: string
}
@@ -1188,8 +1306,10 @@ export interface JiraGetAttachmentsResponse extends ToolResponse {
content: string
thumbnail: string | null
author: { accountId: string; displayName: string } | null
authorName: string
created: string
}>
files?: Array<{ name: string; mimeType: string; data: string; size: number }>
}
}
@@ -1228,11 +1348,7 @@ export interface JiraAddAttachmentResponse extends ToolResponse {
content: string
}>
attachmentIds: string[]
files: Array<{
name: string
mimeType: string
size: number
}>
files: UserFile[]
}
}
@@ -1280,6 +1396,7 @@ export interface JiraGetWorklogsResponse extends ToolResponse {
worklogs: Array<{
id: string
author: { accountId: string; displayName: string }
authorName: string
updateAuthor: { accountId: string; displayName: string } | null
comment: string | null
started: string
@@ -1310,6 +1427,28 @@ export interface JiraUpdateWorklogResponse extends ToolResponse {
worklogId: string
timeSpent: string | null
timeSpentSeconds: number | null
comment: string | null
author: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
updateAuthor: {
accountId: string
displayName: string
active?: boolean
emailAddress?: string
avatarUrl?: string
accountType?: string
timeZone?: string
} | null
started: string | null
created: string | null
updated: string | null
success: boolean
}
}
@@ -1420,7 +1559,9 @@ export interface JiraGetUsersResponse extends ToolResponse {
displayName: string
emailAddress?: string
avatarUrl?: string
avatarUrls?: Record<string, string> | null
timeZone?: string
self?: string | null
}>
total: number
startAt: number

View File

@@ -1,5 +1,5 @@
import type { JiraUpdateParams, JiraUpdateResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import type { ToolConfig } from '@/tools/types'
export const jiraUpdateTool: ToolConfig<JiraUpdateParams, JiraUpdateResponse> = {
@@ -176,6 +176,7 @@ export const jiraUpdateTool: ToolConfig<JiraUpdateParams, JiraUpdateResponse> =
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Updated issue key (e.g., PROJ-123)' },
summary: { type: 'string', description: 'Issue summary after update' },
},

View File

@@ -1,5 +1,5 @@
import type { JiraUpdateCommentParams, JiraUpdateCommentResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types'
import { extractAdfText, getJiraCloudId, transformUser } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -176,6 +176,7 @@ export const jiraUpdateCommentTool: ToolConfig<JiraUpdateCommentParams, JiraUpda
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Issue key' },
commentId: { type: 'string', description: 'Updated comment ID' },
body: { type: 'string', description: 'Updated comment text' },

View File

@@ -1,5 +1,5 @@
import type { JiraUpdateWorklogParams, JiraUpdateWorklogResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types'
import { extractAdfText, getJiraCloudId, transformUser } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
@@ -186,6 +186,7 @@ export const jiraUpdateWorklogTool: ToolConfig<JiraUpdateWorklogParams, JiraUpda
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
issueKey: { type: 'string', description: 'Issue key' },
worklogId: { type: 'string', description: 'Updated worklog ID' },
timeSpent: { type: 'string', description: 'Human-readable time spent (e.g., "3h 20m")' },

View File

@@ -1,3 +1,9 @@
import { createLogger } from '@sim/logger'
const logger = createLogger('JiraUtils')
const MAX_ATTACHMENT_SIZE = 50 * 1024 * 1024
/**
* Extracts plain text from Atlassian Document Format (ADF) content.
* Returns null if content is falsy.
@@ -38,6 +44,58 @@ export function transformUser(user: any): {
}
}
/**
* Downloads Jira attachment file content given attachment metadata and an access token.
* Returns an array of downloaded files with base64-encoded data.
*/
export async function downloadJiraAttachments(
attachments: Array<{
content: string
filename: string
mimeType: string
size: number
id: string
}>,
accessToken: string
): Promise<Array<{ name: string; mimeType: string; data: string; size: number }>> {
const downloaded: Array<{ name: string; mimeType: string; data: string; size: number }> = []
for (const att of attachments) {
if (!att.content) continue
if (att.size > MAX_ATTACHMENT_SIZE) {
logger.warn(`Skipping attachment ${att.filename} (${att.size} bytes): exceeds size limit`)
continue
}
try {
const response = await fetch(att.content, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: '*/*',
},
})
if (!response.ok) {
logger.warn(`Failed to download attachment ${att.filename}: HTTP ${response.status}`)
continue
}
const arrayBuffer = await response.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
downloaded.push({
name: att.filename || `attachment-${att.id}`,
mimeType: att.mimeType || 'application/octet-stream',
data: buffer.toString('base64'),
size: buffer.length,
})
} catch (error) {
logger.warn(`Failed to download attachment ${att.filename}:`, error)
}
}
return downloaded
}
export async function getJiraCloudId(domain: string, accessToken: string): Promise<string> {
const response = await fetch('https://api.atlassian.com/oauth/token/accessible-resources', {
method: 'GET',
@@ -49,7 +107,6 @@ export async function getJiraCloudId(domain: string, accessToken: string): Promi
const resources = await response.json()
// If we have resources, find the matching one
if (Array.isArray(resources) && resources.length > 0) {
const normalizedInput = `https://${domain}`.toLowerCase()
const matchedResource = resources.find((r) => r.url.toLowerCase() === normalizedInput)
@@ -59,8 +116,6 @@ export async function getJiraCloudId(domain: string, accessToken: string): Promi
}
}
// If we couldn't find a match, return the first resource's ID
// This is a fallback in case the URL matching fails
if (Array.isArray(resources) && resources.length > 0) {
return resources[0].id
}

View File

@@ -23,7 +23,7 @@ export const jiraWriteTool: ToolConfig<JiraWriteParams, JiraWriteResponse> = {
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
projectId: {
@@ -214,6 +214,7 @@ export const jiraWriteTool: ToolConfig<JiraWriteParams, JiraWriteResponse> = {
issueKey: { type: 'string', description: 'Created issue key (e.g., PROJ-123)' },
self: { type: 'string', description: 'REST API URL for the created issue' },
summary: { type: 'string', description: 'Issue summary' },
success: { type: 'boolean', description: 'Whether the issue was created successfully' },
url: { type: 'string', description: 'URL to the created issue in Jira' },
assigneeId: {
type: 'string',