CherryHQ/cherry-studio · error · Error

Invalid JSON tool arguments for

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.

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.

Appendix: 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)