Skip to content

Optional string fields that share a Zod schema render as JSON editors ($ref not resolved) #2321

Description

@mulezi1029

Summary

When two (or more) tool parameters are built from the same Zod schema instance (e.g. z.string().regex(...) reused for dateRangeBegin and dateRangeEnd), Inspector shows the first field as a string input and later fields as a JSON editor. Typing a normal date string such as 2026-09-27 then fails with:

Not valid JSON – this field will be omitted

The value is omitted from the tool call even though it is a valid string for the server schema.

Environment

  • MCP Inspector: v2.6.0 (@modelcontextprotocol/inspector@2.6.0)
  • Client: Web UI
  • Server: custom MCP server using Zod 3 (JSON Schema produced via the usual Zod → JSON Schema path used by MCP SDKs)
  • Transport: Streamable HTTP

Reproduction

  1. Register a tool whose input schema is produced from Zod like this:
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);

server.tool("example", "demo", {
  dateRangeBegin: dateSchema.optional().describe("start date, yyyy-MM-dd"),
  dateRangeEnd: dateSchema.optional().describe("end date, yyyy-MM-dd"),
}, handler);
  1. Open the tool in Inspector v2.6.0 Web UI.
  2. Observe:
    • dateRangeBegin → text input
    • dateRangeEnd → JSON textarea
  3. Type 2026-09-27 into dateRangeEnd (no quotes).

A minimal public-style schema that reproduces the same Inspector widgets (after Zod/JSON Schema conversion) looks like:

{
  "type": "object",
  "properties": {
    "dateRangeBegin": {
      "type": "string",
      "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
      "description": "start date, yyyy-MM-dd"
    },
    "dateRangeEnd": {
      "$ref": "#/$defs/DateString"
    }
  },
  "$defs": {
    "DateString": {
      "type": "string",
      "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
    }
  }
}

($ref target may be #/definitions/... depending on the converter.)

Expected

Both fields are optional strings. Both should use the string control. 2026-09-27 should be sent as a string.

Actual

dateRangeEnd is treated as JSON. Unquoted 2026-09-27 is invalid JSON, so Inspector shows the error and drops the field. Wrapping it as "2026-09-27" works around the UI, but the control is still wrong.

Likely cause

Zod → JSON Schema converters deduplicate identical Zod nodes with $ref. Inspector appears to pick the widget from the top-level type of each property. A bare $ref has no type: "string", so it falls back to the JSON editor.

This is easy to hit: a shared dateSchema / timeSchema is a normal way to keep two fields consistent.

Suggested fix

When choosing a widget, resolve $ref / $defs / definitions (and anyOf / allOf wrappers from .optional()) before checking type:

  • resolved type === "string" (optional pattern / format) → string input
  • resolved type === "number" / integer → number input
  • use the JSON editor only when the resolved schema is object / array / unknown

Alternatively, inline $ref for primitive schemas in the UI layer so optional strings keep type: "string".

Workaround (server-side)

Do not reuse one Zod instance. Build two separate schemas:

dateRangeBegin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("..."),
dateRangeEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("..."),

Then both properties are inlined as { "type": "string", "pattern": "..." } and Inspector shows two text inputs.

Additional context

This does not mean the MCP server defined different types. Both parameters are the same optional yyyy-MM-dd string. Reproduced on Inspector v2.6.0 Web UI against a Streamable HTTP MCP server.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions