Skip to content

Bug: Zod options ordering crash in Interface.#insertOptions() #41

@a6b8

Description

@a6b8

Bug

FlowMCP.prepareServerTool() crashes with _interface.min is not a function when schema parameters have default() or optional() before min()/max()/length()/regex() in the options array.

Affected: callListTools, run, getZodInterfaces — everything that builds Zod schemas.
Not affected: fetch, getAllTests — these don't build Zod schemas.

Root Cause

File: src/task/Interface.mjs#insertOptions() (line 105)

Options are applied in the order defined in the schema:

options: ["default(20)", "min(1)", "max(100)"]

Chain:

  1. z.number()ZodNumber (has .min(), .max(), .default())
  2. .default(20)ZodDefault (wrapper — has NO .min())
  3. .min(1)CRASH _interface.min is not a function

Zod's architecture: .default() and .optional() return wrapper objects (ZodDefault, ZodOptional) that don't have constraint methods (.min(), .max(), .length(), .regex()). Constraints must be applied BEFORE wrappers.

Affected Schemas in flowmcp-schemas

Schema Parameter Broken Options
aave/aave.mjs:17 first (getReserves) ["default(20)", "min(1)", "max(100)"]
aave/aave.mjs:32 first (getUserData) ["default(10)", "min(1)", "max(50)"]
web3-career/job-listings.mjs:21 limit ["optional()", "min(1)", "max(100)", "default(50)"]

Suggested Fix

Sort options before applying: constraints first, wrappers (optional, default) last.

static #insertOptions( { _interface, options } ) {
    const wrapperPrefixes = [ 'optional', 'default' ]

    const sortedOptions = [ ...options ]
        .sort( ( a, b ) => {
            const aIsWrapper = wrapperPrefixes
                .some( ( prefix ) => a.startsWith( prefix ) )
            const bIsWrapper = wrapperPrefixes
                .some( ( prefix ) => b.startsWith( prefix ) )

            if( aIsWrapper && !bIsWrapper ) { return 1 }
            if( !aIsWrapper && bIsWrapper ) { return -1 }

            return 0
        } )

    _interface = sortedOptions
        .reduce( ( acc, option ) => {
            _interface = Interface
                .#insertOption( { _interface, option } )

            return _interface
        }, _interface )

    return _interface
}

Effect: ["default(20)", "min(1)", "max(100)"] becomes ["min(1)", "max(100)", "default(20)"]

Additional Bug in #insertOptions

The current reduce has a logic error — acc is never properly updated:

// CURRENT (buggy — acc always returns the ORIGINAL)
_interface = options
    .reduce( ( acc, option ) => {
        _interface = Interface.#insertOption( { _interface, option } )
        return acc    // ← always returns original, not updated value
    }, _interface )

// FIX — return _interface instead of acc
return _interface  // ← return the updated value

Works by accident due to closure mutation, but is logically incorrect.

Reproduction

# In flowmcp-cli with aave schema in a group:
flowmcp call list-tools
# Shows: error_getReserves_aave with "Error: _interface.min is not a function"
# But: flowmcp test works fine for same routes (no Zod involved in fetch)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions