CherryHQ/cherry-studio · error · Error

Invalid JSON tool arguments for ${name}: ${(e as Error).mess

Error message

Invalid JSON tool arguments for ${name}: ${(e as Error).message}

What it means

Thrown when calling an MCP tool whose arguments arrive as a string that fails JSON.parse. The runtime expects tool args to be an object/record; if a string is passed, it attempts to parse it into an object. Empty strings are coerced to {}, but non-empty malformed strings trigger this fail-fast guard to prevent opaque downstream errors from the MCP server.

Source

Thrown at src/main/ai/mcp/McpRuntimeService.ts:1226

    const callToolFunc = async ({ server, name, args }: RuntimeCallToolArgs) => {
      try {
        // Inside the try so an already-aborted signal still hits the finally cleanup below.
        if (effectiveSignal.aborted) {
          throw getAbortReason(effectiveSignal)
        }
        getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Calling tool`, {
          args: redactSensitive(args)
        })
        if (typeof args === 'string') {
          if (args.trim() === '') {
            args = {}
          } else {
            try {
              args = JSON.parse(args)
            } catch (e) {
              // Fail fast instead of forwarding malformed JSON as a raw string — the MCP
              // server expects an object/record, so a bare string yields opaque downstream errors.
              throw new Error(`Invalid JSON tool arguments for ${name}: ${(e as Error).message}`)
            }
          }
        }
        const sourcePolicy = this.getLatestSourcePolicy(server)
        if (isMcpToolDisabledBySource(sourcePolicy, { name })) {
          throw new Error(`MCP tool is disabled: ${name}`)
        }
        // Client init (ping probe, transport connect, OAuth) has no unified timeout at this
        // layer — release this call's wait on abort instead of blocking until it settles.
        // The shared `pendingClients` init keeps running (only this caller's wait is released),
        // and both racers are consumed, so the loser's late rejection is never unhandled.
        // The listener is removed once the race settles: `once` only cleans up after an
        // abort fires, and the composed signal is retained by the long-lived stream signal —
        // leaving it installed would accumulate a closure per tool call.
        let handleAbort: (() => void) | undefined
        const client = await Promise.race([
          this.getOrCreateClient(server),
          new Promise<never>((_, reject) => {

View on GitHub (pinned to 726446b54c)

Solutions

  1. Ensure the LLM prompt instructs the model to output JSON for tool arguments
  2. If passing args programmatically, pass a JavaScript object directly instead of a JSON string
  3. Add a pre-validation step that attempts JSON.parse on string args before calling the runtime, with a fallback to a default object
  4. Check for truncation in the streaming layer if the JSON appears cut off

Example fix

// before
const args = 'search for cats'  // malformed — not JSON
await runtime.callToolByServer({ server, name: 'search', args })

// after
const args = { query: 'cats' }  // pass as object
await runtime.callToolByServer({ server, name: 'search', args })
Defensive patterns

Strategy: validation

Validate before calling

function normalizeToolArgs(args: unknown): Record<string, unknown> {
  if (args == null) return {}
  if (typeof args === 'object') return args as Record<string, unknown>
  if (typeof args === 'string') {
    const trimmed = args.trim()
    if (trimmed === '') return {}
    try {
      return JSON.parse(trimmed)
    } catch {
      throw new Error(`Tool args are not valid JSON: ${trimmed.slice(0, 100)}`)
    }
  }
  return {}
}

// Use before calling
const normalized = normalizeToolArgs(rawArgs)
await runtime.callToolByServer({ server, name, args: normalized })

Type guard

function isToolArgsObject(args: unknown): args is Record<string, unknown> {
  return typeof args === 'object' && args !== null && !Array.isArray(args)
}

Try / catch

try {
  await runtime.callToolByServer({ server, name, args })
} catch (e) {
  if (e instanceof Error && e.message.startsWith('Invalid JSON tool arguments')) {
    // Fallback: call with empty args or skip the tool
    logger.warn(`Malformed tool args for ${name}, using empty args`)
    await runtime.callToolByServer({ server, name, args: {} })
    return
  }
  throw e
}

Prevention

When it happens

Trigger: An LLM generates a tool call with arguments as a raw string that isn't valid JSON (e.g. 'search for cats' instead of '{"query": "cats"}'), or a programmatic caller passes a non-JSON string. The catch block wraps the parse error with the tool name for debugging.

Common situations: LLM model outputs unstructured text as tool arguments instead of JSON; a streaming/parsing layer truncates the JSON mid-string; the caller serializes an object with a custom (non-JSON) method; Unicode encoding issues corrupt the JSON payload.

Understand the failure class

Related errors


AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12). Data as JSON: /api/errors/d9550c2457969ff7. Report an issue: GitHub.