Skip to content

fix(sequential-thinking): Use z.coerce for inputSchema to handle string-typed parameters from LLM clients #3428

@bivlked

Description

@bivlked

Problem

Multiple MCP clients (Claude Code with Opus 4.6/Sonnet 4.5, Augment.AI, and others) intermittently send thoughtNumber, totalThoughts, and nextThoughtNeeded as strings instead of native JSON numbers/booleans. For example, "1" instead of 1, or "true" instead of true.

This causes a Zod validation error:

Invalid arguments for tool sequentialthinking:
  "expected": "number", "received": "string" (thoughtNumber)
  "expected": "number", "received": "string" (totalThoughts)
  "expected": "boolean", "received": "string" (nextThoughtNeeded)

This is the same issue reported in #2792, with a fix proposed (but not merged) in #2812.

Root cause

The behavior is non-deterministic on the LLM side — the same model in the same session may send correct types for one call and string types for the next. This has been independently confirmed by multiple users across different clients and models.

The current inputSchema uses strict Zod types:

thoughtNumber: z.number().int().min(1)
nextThoughtNeeded: z.boolean()

These reject any string input, even valid ones like "1" or "true".

Proposed fix: z.coerce

Replace z.number() with z.coerce.number() and z.boolean() with z.coerce.boolean() in inputSchema only:

--- a/src/sequentialthinking/index.ts
+++ b/src/sequentialthinking/index.ts
@@ -66,13 +66,13 @@
     inputSchema: {
       thought: z.string().describe("Your current thinking step"),
-      nextThoughtNeeded: z.boolean().describe("Whether another thought step is needed"),
-      thoughtNumber: z.number().int().min(1).describe("Current thought number (numeric value, e.g., 1, 2, 3)"),
-      totalThoughts: z.number().int().min(1).describe("Estimated total thoughts needed (numeric value, e.g., 5, 10)"),
-      isRevision: z.boolean().optional().describe("Whether this revises previous thinking"),
-      revisesThought: z.number().int().min(1).optional().describe("Which thought is being reconsidered"),
-      branchFromThought: z.number().int().min(1).optional().describe("Branching point thought number"),
+      nextThoughtNeeded: z.coerce.boolean().describe("Whether another thought step is needed"),
+      thoughtNumber: z.coerce.number().int().min(1).describe("Current thought number (numeric value, e.g., 1, 2, 3)"),
+      totalThoughts: z.coerce.number().int().min(1).describe("Estimated total thoughts needed (numeric value, e.g., 5, 10)"),
+      isRevision: z.coerce.boolean().optional().describe("Whether this revises previous thinking"),
+      revisesThought: z.coerce.number().int().min(1).optional().describe("Which thought is being reconsidered"),
+      branchFromThought: z.coerce.number().int().min(1).optional().describe("Branching point thought number"),
       branchId: z.string().optional().describe("Branch identifier"),
-      needsMoreThoughts: z.boolean().optional().describe("If more thoughts are needed")
+      needsMoreThoughts: z.coerce.boolean().optional().describe("If more thoughts are needed")
     },

Why z.coerce is the right approach

Approach Lines changed Drawbacks
Node.js wrapper (stdin proxy) ~100 Extra process, fragile JSON parsing, maintenance burden
Manual sanitizeNumericParam() (#2812) ~30 Duplicates Zod's built-in capability, needs tests for edge cases
z.coerce (this proposal) 9 None — uses Zod's native type coercion

z.coerce.number() is a first-class Zod API (docs) that:

  • Accepts both 1 (number) and "1" (string) → coerces to 1
  • Rejects "abc" → validation error (same as before)
  • Preserves .int().min(1) chain — all downstream validators still apply
  • Requires zero additional code — just a prefix change

z.coerce.boolean() similarly:

  • Accepts both true and "true" → coerces to true
  • Works with the existing .optional() chain

What stays unchanged

  • outputSchema — remains strict (z.number(), z.boolean()), since the server controls its own output types
  • All existing validation constraints (.int(), .min(1), .optional()) — fully preserved
  • No new dependencies — z.coerce has been available since Zod 3.20

Tested with

  • Claude Code (Opus 4.6) on Windows 11 — previously failing, now works reliably
  • Applied as a local patch to dist/index.js (npm v2025.12.18), confirmed working across multiple sessions

Related

I would be happy to submit a PR with this change and corresponding tests if the maintainers are interested. Thank you for your work on this server!

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