diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx index 3994248ef6d..08addbf7ab4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { Combobox, FieldDivider, Label, Slider, Switch } from '@sim/emcn' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' @@ -42,6 +42,27 @@ function requiresJsonValue(paramSchema: any): boolean { ) } +/** + * Stable signature of an entire tool schema, for detecting whether the effective + * param shape has changed (independent of object identity). Signs the whole schema + * rather than cherry-picking fields (e.g. just `properties`) so a refresh that only + * changes `required`, or any other schema-level field, isn't silently missed. + */ +function schemaSignature(schema: unknown): string { + return schema ? JSON.stringify(schema) : '' +} + +/** + * True when text looks like an attempted JSON array/object literal (starts with `[` + * or `{`), as opposed to plain freeform text. Used to tell an in-progress, incomplete + * JSON literal (which must not persist until valid — see `requiresJsonValue`) apart + * from the comma-separated/plain-text shorthand array params also accept. + */ +function looksLikeJsonLiteral(text: string): boolean { + const trimmed = text.trim() + return trimmed.startsWith('[') || trimmed.startsWith('{') +} + interface McpDynamicArgsProps { blockId: string subBlockId: string @@ -97,6 +118,58 @@ export function McpDynamicArgs({ const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool) const toolSchema = cachedSchema || selectedToolConfig?.inputSchema + /** + * Draft text for JSON-value params (object/array/non-primitive-enum) whose current + * edit isn't valid JSON yet, paired with a signature of the persisted value it was + * typed against. Keeping this out of toolArgs means the stored argument is always + * either the last valid parsed value or untouched — never malformed text that could + * reach tool execution. A draft is only displayed while its baseline still matches + * the live persisted value, so an external change to that value (undo/redo, a diff + * baseline switch, a collaborator's edit) can't be shadowed by stale draft text. + * Drafts also reset wholesale on either of two independent triggers: + * - the selected tool or the cached `_toolSchema` snapshot changes (this pair + * always drives `toolSchema` whenever a cached snapshot exists) — the live + * schema tracker is also re-baselined to the new tool's current signature + * here (even if still empty), so a tool switch never leaves the *previous* + * tool's signature behind to be misread as a "refresh" once the new tool's + * schema loads a moment later, or + * - for the *same* tool, the live discovered schema's signature changes to a + * different non-empty value than the last non-empty value actually observed + * — a genuine re-discovery/refresh. Comparing against the last known + * non-empty value (rather than merely the previous render's value) means a + * schema that transiently disappears and reappears — e.g. `mcpTools` + * refetching — still resets drafts if it comes back different, while a + * plain empty → non-empty transition (the initial async load) does not, + * since there is no prior non-empty value for this tool to compare against. + */ + const [invalidJsonDrafts, setInvalidJsonDrafts] = useState< + Record + >({}) + const toolAndCachedSchemaKey = `${selectedTool ?? ''}|${schemaSignature(cachedSchema)}` + const liveSchemaSignature = schemaSignature(selectedToolConfig?.inputSchema) + const [prevToolAndCachedSchemaKey, setPrevToolAndCachedSchemaKey] = + useState(toolAndCachedSchemaKey) + const [lastNonEmptyLiveSchemaSignature, setLastNonEmptyLiveSchemaSignature] = + useState(liveSchemaSignature) + if (prevToolAndCachedSchemaKey !== toolAndCachedSchemaKey) { + setInvalidJsonDrafts({}) + setPrevToolAndCachedSchemaKey(toolAndCachedSchemaKey) + setLastNonEmptyLiveSchemaSignature(liveSchemaSignature) + } else { + const nextLastNonEmptyLiveSchemaSignature = + liveSchemaSignature !== '' ? liveSchemaSignature : lastNonEmptyLiveSchemaSignature + if (nextLastNonEmptyLiveSchemaSignature !== lastNonEmptyLiveSchemaSignature) { + const isGenuineLiveRefresh = + liveSchemaSignature !== '' && + lastNonEmptyLiveSchemaSignature !== '' && + liveSchemaSignature !== lastNonEmptyLiveSchemaSignature + if (isGenuineLiveRefresh) { + setInvalidJsonDrafts({}) + } + setLastNonEmptyLiveSchemaSignature(nextLastNonEmptyLiveSchemaSignature) + } + } + const currentArgs = useCallback(() => { if (isPreview && previewValue) { if (typeof previewValue === 'string') { @@ -158,7 +231,7 @@ export function McpDynamicArgs({ if (paramSchema.maxLength && paramSchema.maxLength > 100) return 'long-input' return 'short-input' } - if (paramSchema.type === 'array') return 'long-input' + if (paramSchema.type === 'array' || paramSchema.type === 'object') return 'long-input' return 'short-input' } @@ -270,8 +343,17 @@ export function McpDynamicArgs({ case 'long-input': { const config = createParamConfig(paramName, paramSchema, 'long-input') + const needsJsonValue = requiresJsonValue(paramSchema) + const valueSignature = JSON.stringify(value ?? null) + const draft = invalidJsonDrafts[paramName] + const activeDraft = + needsJsonValue && draft && draft.baseline === valueSignature ? draft.text : undefined const displayValue = - typeof value === 'string' || value == null ? value || '' : JSON.stringify(value) + activeDraft !== undefined + ? activeDraft + : typeof value === 'string' || value == null + ? value || '' + : JSON.stringify(value) return ( { - if (!requiresJsonValue(paramSchema)) { + if (!needsJsonValue) { updateParameter(paramName, newValue) return } + const clearDraft = () => + setInvalidJsonDrafts((prev) => { + if (!(paramName in prev)) return prev + const { [paramName]: _removed, ...rest } = prev + return rest + }) + if (newValue === '') { + updateParameter(paramName, '') + clearDraft() + return + } try { updateParameter(paramName, JSON.parse(newValue)) + clearDraft() } catch { - updateParameter(paramName, newValue) + if (paramSchema.type === 'array' && !looksLikeJsonLiteral(newValue)) { + updateParameter(paramName, newValue) + clearDraft() + return + } + setInvalidJsonDrafts((prev) => ({ + ...prev, + [paramName]: { text: newValue, baseline: valueSignature }, + })) } }} isPreview={isPreview}