mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-10 22:55:16 -05:00
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:
@@ -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 |
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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")' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user