Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/sim/executor/variables/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ import {
LARGE_ARRAY_MANIFEST_VERSION,
type LargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import {
collectSandboxFileMountRefs,
replaceSandboxFileMountRefs,
} from '@/lib/execution/payloads/sandbox-file-mount-ref'
import { StartBlockPath } from '@/lib/workflows/triggers/triggers'
import { BlockType } from '@/executor/constants'
import { ExecutionState } from '@/executor/execution/state'
import type { ExecutionContext } from '@/executor/types'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import { buildStartBlockOutput } from '@/executor/utils/start-block'
import { VariableResolver } from '@/executor/variables/resolver'
import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
Expand Down Expand Up @@ -127,6 +133,79 @@ function createResolver(
}
}

describe('Start file path references', () => {
const workspaceId = '11111111-1111-4111-8111-111111111111'
const key = `workspace/${workspaceId}/photo.png`
const uploadedFile = {
id: 'file-1',
name: 'photo.png',
size: 128,
type: 'image/png',
}

it.each([
{
language: 'shell',
file: { ...uploadedFile, key, url: 'https://storage.example.com/photo.png' },
},
{
language: 'shell',
file: {
...uploadedFile,
url: `/api/files/serve/s3/${encodeURIComponent(key)}?context=workspace`,
},
},
{
language: 'python',
file: { ...uploadedFile, key, url: 'https://storage.example.com/photo.png' },
},
{
language: 'javascript',
file: { ...uploadedFile, key, url: 'https://storage.example.com/photo.png' },
},
])('mounts a Start upload referenced from $language code', async ({ language, file }) => {
const start = createBlock('start', 'Start', 'start_trigger', {
inputFormat: [{ name: 'files', type: 'file[]', value: '' }],
})
const functionBlock = createBlock('function', 'Function', BlockType.FUNCTION, { language })
const output = buildStartBlockOutput({
resolution: { blockId: start.id, block: start, path: StartBlockPath.UNIFIED },
workspaceId,
workflowInput: { input: 'Edit the image', files: [file] },
})
const { ctx } = createResolver(language)
const state = new ExecutionState()
state.setBlockOutput(start.id, output)
ctx.blockStates = state.getBlockStates()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [start, functionBlock],
connections: [],
loops: {},
parallels: {},
}
const resolver = new VariableResolver(workflow, {}, state, { navigatePathAsync })
const result = await resolver.resolveInputsForFunctionBlock(
ctx,
functionBlock.id,
{ code: 'IN="<start.files[0].path>"' },
functionBlock
)

expect(collectSandboxFileMountRefs(result.contextVariables)).toEqual([
{ ...file, key, context: 'workspace' },
])
expect(
replaceSandboxFileMountRefs(result.contextVariables, () => '/tmp/sim/inputs/photo.png')
).toEqual({ __blockRef_0: '/tmp/sim/inputs/photo.png' })
expect(result.resolvedInputs.code).not.toContain('<start.files[0].path>')
expect(result.resolvedInputs.code).not.toContain('null')
if (language === 'shell') {
expect(result.resolvedInputs.code).toBe(`IN="\${__blockRef_0}"`)
}
})
})

/** Runs one condition expression through the resolver and returns the value the handler receives. */
async function resolveConditionExpression(
value: string,
Expand Down
173 changes: 12 additions & 161 deletions apps/sim/lib/mcp/workflow-tool-schema.ts
Original file line number Diff line number Diff line change
@@ -1,119 +1,9 @@
import { z } from 'zod'
import type { McpToolSchema, McpToolSchemaProperty } from '@/lib/mcp/types'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { generateWorkflowInputShape } from '@/lib/workflows/input-schema'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import type { McpToolSchema } from './types'

/**
* Extended property definition for workflow tool schemas.
* More specific than the generic McpToolSchema properties.
*/
export interface McpToolProperty {
[key: string]: unknown
type: string
description?: string
items?: McpToolProperty
properties?: Record<string, McpToolProperty>
}

/**
* Extended MCP tool schema with typed properties (for workflow tool generation).
* Extends the base McpToolSchema with more specific property types.
*/
export interface McpToolInputSchema extends McpToolSchema {
properties: Record<string, McpToolProperty>
}

export interface McpToolDefinition {
name: string
description: string
inputSchema: McpToolInputSchema
}

/**
* File item Zod schema for MCP file inputs.
* This is the single source of truth for file structure.
*/
export const fileItemZodSchema = z.object({
name: z.string().describe('File name'),
data: z.string().describe('Base64 encoded file content'),
mimeType: z.string().describe('MIME type of the file'),
})

/**
* Convert InputFormatField type to Zod schema
*/
function fieldTypeToZod(fieldType: string | undefined, isRequired: boolean): z.ZodTypeAny {
let zodType: z.ZodTypeAny

switch (fieldType) {
case 'string':
zodType = z.string()
break
case 'number':
zodType = z.number()
break
case 'boolean':
zodType = z.boolean()
break
case 'object':
zodType = z.record(z.string(), z.any())
break
case 'array':
zodType = z.array(z.any())
break
case 'files':
zodType = z.array(fileItemZodSchema)
break
default:
zodType = z.string()
}

return isRequired ? zodType : zodType.optional()
}

/**
* Generate Zod schema shape from InputFormatField array.
* This is used directly by the MCP server for tool registration.
*/
export function generateToolZodSchema(inputFormat: InputFormatField[]): z.ZodRawShape | undefined {
if (!inputFormat || inputFormat.length === 0) {
return undefined
}

const shape: Record<string, z.ZodTypeAny> = {}

for (const field of inputFormat) {
if (!field.name) continue

const zodType = fieldTypeToZod(field.type, true)
shape[field.name] = field.name ? zodType.describe(field.name) : zodType
}

return Object.keys(shape).length > 0 ? shape : undefined
}

/**
* Map InputFormatField type to JSON Schema type (for database storage)
*/
function mapFieldTypeToJsonSchemaType(fieldType: string | undefined): string {
switch (fieldType) {
case 'string':
return 'string'
case 'number':
return 'number'
case 'boolean':
return 'boolean'
case 'object':
return 'object'
case 'array':
return 'array'
case 'files':
return 'array'
default:
return 'string'
}
}

/**
* Sanitize a workflow name to be a valid MCP tool name.
Expand All @@ -136,54 +26,15 @@ export function sanitizeToolName(name: string): string {
* This converts the workflow's input format definition to JSON Schema format
* that MCP clients can use to understand tool parameters.
*/
export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolInputSchema {
const properties: Record<string, McpToolProperty> = {}
const required: string[] = []

for (const field of inputFormat) {
if (!field.name) continue

const fieldName = field.name
const fieldType = mapFieldTypeToJsonSchemaType(field.type)

const property: McpToolProperty = {
type: fieldType,
// Use custom description if provided, otherwise use field name
description: field.description?.trim() || fieldName,
}

// Handle array types
if (fieldType === 'array') {
if (field.type === 'file[]') {
property.items = {
type: 'object',
properties: {
name: { type: 'string', description: 'File name' },
url: { type: 'string', description: 'File URL' },
type: { type: 'string', description: 'MIME type' },
size: { type: 'number', description: 'File size in bytes' },
},
}
// Use custom description if provided, otherwise use default
if (!field.description?.trim()) {
property.description = 'Array of file objects'
}
} else {
property.items = { type: 'string' }
}
}

properties[fieldName] = property

// All fields are considered required by default
// (in the future, we could add an optional flag to InputFormatField)
required.push(fieldName)
}

export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolSchema {
const schema = z.toJSONSchema(z.object(generateWorkflowInputShape(inputFormat)), {
target: 'draft-07',
io: 'input',
})
return {
type: 'object',
properties,
required: required.length > 0 ? required : undefined,
properties: schema.properties as Record<string, McpToolSchemaProperty>,
...(schema.required?.length ? { required: schema.required } : {}),
}
}

Expand All @@ -198,10 +49,10 @@ export function applyDescriptionOverrides(
overrides: Record<string, string> | null | undefined
): Record<string, unknown> {
if (!overrides || Object.keys(overrides).length === 0) return baseSchema
const baseProperties = baseSchema.properties as Record<string, McpToolProperty> | undefined
const baseProperties = baseSchema.properties as Record<string, McpToolSchemaProperty> | undefined
if (!baseProperties) return baseSchema

const properties: Record<string, McpToolProperty> = {}
const properties: Record<string, McpToolSchemaProperty> = {}
for (const [name, property] of Object.entries(baseProperties)) {
const override = overrides[name]
properties[name] =
Expand Down Expand Up @@ -244,7 +95,7 @@ export function extractDescriptionOverrides(
| Record<string, { description?: unknown }>
| undefined
if (!schemaProperties) return overrides
const baseProperties = (baseSchema.properties ?? {}) as Record<string, McpToolProperty>
const baseProperties = (baseSchema.properties ?? {}) as Record<string, McpToolSchemaProperty>

for (const [name, property] of Object.entries(schemaProperties)) {
if (!(name in baseProperties)) continue
Expand Down
Loading
Loading