From 6d16f216c8eeaa8ded4ef47133e8487bbe7dbceb Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 10 Feb 2026 17:08:57 -0800 Subject: [PATCH] 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 --- apps/docs/content/docs/en/tools/jira.mdx | 37 +- .../mcp/workflow-servers/[id]/tools/route.ts | 8 +- .../sim/app/api/mcp/workflow-servers/route.ts | 5 +- .../[id]/invitations/[invitationId]/route.ts | 49 ++- .../api/tools/jira/add-attachment/route.ts | 9 +- .../deploy-modal/components/mcp/mcp.tsx | 243 ++++++----- .../components/tool-credential-selector.tsx | 11 +- .../components/tool-input/tool-input.tsx | 2 +- .../components/custom-tools/custom-tools.tsx | 10 +- .../settings-modal/components/mcp/mcp.tsx | 391 +++++++++++++++++- .../skills/components/skill-modal.tsx | 26 +- .../components/skills/skills.tsx | 22 +- .../member-invitation-card.tsx | 111 ++--- .../team-management/team-management.tsx | 20 +- apps/sim/blocks/blocks/confluence.ts | 4 +- apps/sim/blocks/blocks/jira.ts | 2 +- .../blocks/blocks/jira_service_management.ts | 2 +- apps/sim/executor/execution/block-executor.ts | 4 +- apps/sim/hooks/queries/mcp.ts | 4 + apps/sim/hooks/queries/organization.ts | 6 +- .../tool-executor/deployment-tools/deploy.ts | 12 +- .../tool-executor/deployment-tools/manage.ts | 9 +- apps/sim/lib/mcp/workflow-mcp-sync.ts | 87 +++- apps/sim/tools/jira/add_attachment.ts | 14 +- apps/sim/tools/jira/add_comment.ts | 3 +- apps/sim/tools/jira/add_watcher.ts | 3 +- apps/sim/tools/jira/add_worklog.ts | 3 +- apps/sim/tools/jira/assign_issue.ts | 3 +- apps/sim/tools/jira/create_issue_link.ts | 5 +- apps/sim/tools/jira/delete_attachment.ts | 3 +- apps/sim/tools/jira/delete_comment.ts | 3 +- apps/sim/tools/jira/delete_issue.ts | 3 +- apps/sim/tools/jira/delete_issue_link.ts | 3 +- apps/sim/tools/jira/delete_worklog.ts | 3 +- apps/sim/tools/jira/get_attachments.ts | 24 +- apps/sim/tools/jira/get_comments.ts | 1 + apps/sim/tools/jira/get_users.ts | 16 +- apps/sim/tools/jira/get_worklogs.ts | 1 + apps/sim/tools/jira/remove_watcher.ts | 3 +- apps/sim/tools/jira/retrieve.ts | 36 +- apps/sim/tools/jira/search_issues.ts | 2 + apps/sim/tools/jira/transition_issue.ts | 3 +- apps/sim/tools/jira/types.ts | 183 +++++++- apps/sim/tools/jira/update.ts | 3 +- apps/sim/tools/jira/update_comment.ts | 3 +- apps/sim/tools/jira/update_worklog.ts | 3 +- apps/sim/tools/jira/utils.ts | 61 ++- apps/sim/tools/jira/write.ts | 3 +- 48 files changed, 1097 insertions(+), 365 deletions(-) diff --git a/apps/docs/content/docs/en/tools/jira.mdx b/apps/docs/content/docs/en/tools/jira.mdx index 179d7023a..df97c0957 100644 --- a/apps/docs/content/docs/en/tools/jira.mdx +++ b/apps/docs/content/docs/en/tools/jira.mdx @@ -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 | diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 6705d5298..54b73fe86 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -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('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('write')( workflowId: body.workflowId, toolName, toolDescription, - parameterSchema: body.parameterSchema || {}, + parameterSchema, createdAt: new Date(), updatedAt: new Date(), }) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 1779e51a9..12c9de391 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -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(), }) diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index cd716fe15..d28e545fd 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -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 diff --git a/apps/sim/app/api/tools/jira/add-attachment/route.ts b/apps/sim/app/api/tools/jira/add-attachment/route.ts index 63b031032..110a91570 100644 --- a/apps/sim/app/api/tools/jira/add-attachment/route.ts +++ b/apps/sim/app/api/tools/jira/add-attachment/route.ts @@ -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) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx index a3b3206ea..5c82bb472 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx @@ -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>({}) const [pendingServerChanges, setPendingServerChanges] = useState>(new Set()) + const [saveErrors, setSaveErrors] = useState([]) const parameterSchema = useMemo( () => generateParameterSchema(inputFormat, parameterDescriptions), @@ -179,6 +186,7 @@ export function McpDeploy({ } return ids }, [servers, serverToolsMap]) + const [draftSelectedServerIds, setDraftSelectedServerIds] = useState(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 = {} + 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({ - {addToolMutation.isError && ( -

- {addToolMutation.error?.message || 'Failed to add tool'} -

+ {saveErrors.length > 0 && ( +
+ {saveErrors.map((error) => ( +

+ {error} +

+ ))} +
)} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx index 28763bb28..0496489d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx @@ -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] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 9990c3eeb..ff08547ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -2642,7 +2642,7 @@ export const ToolInput = memo(function ToolInput({ {!isCustomTool && isExpandedForDisplay && ( -
+
{/* Operation dropdown for tools with multiple operations */} {(() => { const hasOperations = hasMultipleOperations(tool.type) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/custom-tools/custom-tools.tsx index 33ee8340a..42d2f9f4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/custom-tools/custom-tools.tsx @@ -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() { <>
-
+
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' />
- +
+ + +
+ + + + Edit MCP Server + +
+ + { + if (editTestResult) clearEditTestResult() + setEditFormData((prev) => ({ ...prev, name: e.target.value })) + }} + className='h-9' + /> + + + + handleEditInputChange('url', e.target.value)} + onScroll={setEditUrlScrollLeft} + /> + + +
+
+ + Headers + + +
+ +
+ {(editFormData.headers || []).map((header, index) => ( + handleEditRemoveHeader(index)} + /> + ))} +
+
+
+
+ + {editSaveError && ( +

+ {editSaveError} +

+ )} +
+ +
+ + +
+
+
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal.tsx index 99a473fd2..190b82320 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal.tsx @@ -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({}) 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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/skills.tsx index dadebcd9e..f2aa01379 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/skills.tsx @@ -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() { <>
-
+
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' />
- {/* Invitation error - inline */} {invitationError && (

{invitationError instanceof Error && invitationError.message @@ -302,7 +256,6 @@ export function MemberInvitationCard({

)} - {/* Success message */} {inviteSuccess && (

Invitation sent successfully diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx index b78de6ed9..91448259f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx @@ -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([]) 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 && (

{}} // No-op: data is auto-loaded by React Query + onLoadUserWorkspaces={async () => {}} onWorkspaceToggle={handleWorkspaceToggle} inviteSuccess={inviteSuccess} availableSeats={Math.max(0, totalSeats - usedSeats.used)} diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts index 4c8790928..970945c0c 100644 --- a/apps/sim/blocks/blocks/confluence.ts +++ b/apps/sim/blocks/blocks/confluence.ts @@ -44,7 +44,7 @@ export const ConfluenceBlock: BlockConfig = { 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 = { 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, }, { diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index 60356a728..63e06ea53 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -54,7 +54,7 @@ export const JiraBlock: BlockConfig = { 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', diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index 86ac86e75..11cda3858 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -49,7 +49,7 @@ export const JiraServiceManagementBlock: BlockConfig = { 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', diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index f6de271cb..200a23b5e 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -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): Record { @@ -410,7 +412,7 @@ export class BlockExecutor { } } - return result + return redactApiKeys(result) } private callOnBlockStart( diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index 607cb5e1e..acb8fbe3c 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -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 = * 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) diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 7f4fbe7ee..2b69d387e 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -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, }), }) diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts index 9d8f2b783..7e1607f09 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts @@ -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 }, diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts index dc5d7a988..c93ccea8b 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts @@ -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) } diff --git a/apps/sim/lib/mcp/workflow-mcp-sync.ts b/apps/sim/lib/mcp/workflow-mcp-sync.ts index 447eeefc6..4205043ce 100644 --- a/apps/sim/lib/mcp/workflow-mcp-sync.ts +++ b/apps/sim/lib/mcp/workflow-mcp-sync.ts @@ -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 = 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): Record { +export function generateSchemaFromBlocks(blocks: Record): Record { const inputFormat = extractInputFormatFromBlocks(blocks) if (!inputFormat || inputFormat.length === 0) { - return { type: 'object', properties: {} } + return EMPTY_SCHEMA } return generateToolInputSchema(inputFormat) as unknown as Record } +/** + * 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> { + try { + const deployed = await loadDeployedWorkflowState(workflowId) + if (!deployed?.blocks) return EMPTY_SCHEMA + return generateSchemaFromBlocks(deployed.blocks as Record) + } catch { + return EMPTY_SCHEMA + } +} + interface SyncOptions { workflowId: string requestId: string @@ -42,9 +62,8 @@ export async function syncMcpToolsForWorkflow(options: SyncOptions): Promise } | 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 { 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) + } + })() +} diff --git a/apps/sim/tools/jira/add_attachment.ts b/apps/sim/tools/jira/add_attachment.ts index 07b6e1d16..260bcc029 100644 --- a/apps/sim/tools/jira/add_attachment.ts +++ b/apps/sim/tools/jira/add_attachment.ts @@ -98,18 +98,6 @@ export const jiraAddAttachmentTool: ToolConfig | 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, + }, }, } diff --git a/apps/sim/tools/jira/get_comments.ts b/apps/sim/tools/jira/get_comments.ts index af6ec05de..a043b13bb 100644 --- a/apps/sim/tools/jira/get_comments.ts +++ b/apps/sim/tools/jira/get_comments.ts @@ -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 ?? '', diff --git a/apps/sim/tools/jira/get_users.ts b/apps/sim/tools/jira/get_users.ts index 71cbae350..c67a32a0c 100644 --- a/apps/sim/tools/jira/get_users.ts +++ b/apps/sim/tools/jira/get_users.ts @@ -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 ({ 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 | 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 ({ diff --git a/apps/sim/tools/jira/transition_issue.ts b/apps/sim/tools/jira/transition_issue.ts index 9da0146c6..33f864f94 100644 --- a/apps/sim/tools/jira/transition_issue.ts +++ b/apps/sim/tools/jira/transition_issue.ts @@ -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 }, diff --git a/apps/sim/tools/jira/types.ts b/apps/sim/tools/jira/types.ts index 850ccb36d..527efa3a5 100644 --- a/apps/sim/tools/jira/types.ts +++ b/apps/sim/tools/jira/types.ts @@ -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 @@ -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 + 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 | null timeZone?: string + self?: string | null }> total: number startAt: number diff --git a/apps/sim/tools/jira/update.ts b/apps/sim/tools/jira/update.ts index bf42d6cd2..75f53fc87 100644 --- a/apps/sim/tools/jira/update.ts +++ b/apps/sim/tools/jira/update.ts @@ -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 = { @@ -176,6 +176,7 @@ export const jiraUpdateTool: ToolConfig = 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' }, }, diff --git a/apps/sim/tools/jira/update_comment.ts b/apps/sim/tools/jira/update_comment.ts index d9c273987..526e724fb 100644 --- a/apps/sim/tools/jira/update_comment.ts +++ b/apps/sim/tools/jira/update_comment.ts @@ -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, + accessToken: string +): Promise> { + 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 { 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 } diff --git a/apps/sim/tools/jira/write.ts b/apps/sim/tools/jira/write.ts index 47c8be58b..42a5f9391 100644 --- a/apps/sim/tools/jira/write.ts +++ b/apps/sim/tools/jira/write.ts @@ -23,7 +23,7 @@ export const jiraWriteTool: ToolConfig = { 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 = { 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',