MCP Tool Schema Design#
Every MCP tool in Langfuse uses a deliberate two-schema pattern to decouple what is advertised to MCP clients from what is enforced at runtime. This pattern is defined in defineTool and applied consistently across all prompt tools.
The baseSchema / inputSchema Split#
The DefineToolOptions interface requires two separate Zod schemas:
| Field | Purpose |
|---|---|
baseSchema | "Without refinements" — converted to JSON Schema draft-7 and sent to MCP clients as the tool's advertised input schema |
inputSchema | "Full Zod schema with refinements" — used only at runtime to validate incoming requests before the handler is called |
Inside defineTool, baseSchema is converted using Zod v4's native z.toJSONSchema with target: "draft-7" and unrepresentable: "any". The resulting JSON Schema object is what MCP clients (Claude Code, Cursor, etc.) receive when they discover the tool. The inputSchema.parse(rawInput) call in the wrapped handler is what actually gates execution.
Why the Split Exists: JSON Schema Union-Type Incompatibility#
MCP clients auto-render tool input forms from the JSON Schema they receive. Zod schemas that contain union types (.union(), .discriminatedUnion(), .superRefine()) serialize to oneOf/anyOf in JSON Schema. Some MCP clients mishandle or ignore these union alternatives when rendering array-item forms, causing incorrect UI or silent validation failures.
The workaround is to keep baseSchema flat and free of Zod refinements — using simple, portable types that serialize cleanly — and push all complex validation into inputSchema, which runs server-side only .
Concrete Example: createChatPrompt#
The createChatPrompt tool (source) illustrates both schemas in the same file:
CreateChatPromptBaseSchemauses plainz.string()andz.array(z.object({role, content})). Comment explicitly notes: "Using simple object schema instead of union to comply with MCP spec" .CreateChatPromptInputSchemareplaces those with stricter validators from@langfuse/shared(PromptNameSchema,PromptLabelSchema) and additional error messages.
PR #13323 extended this tool to support placeholder messages (a {type: "placeholder", name} shape alongside {role, content} messages). Rather than introduce a oneOf in baseSchema, it added a flat ChatMessageBaseSchema with all four fields optional, and moved the either/or constraint into inputSchema via .superRefine().
Pattern Usage Across Tools#
The split is applied uniformly across the mcp/features/prompts/tools directory:
| Tool | baseSchema simplification | inputSchema constraint |
|---|---|---|
createChatPrompt | Simple z.string() + object array | PromptNameSchema, PromptLabelSchema, .superRefine() for placeholder/content discriminant |
createTextPrompt | Plain string validators | PromptNameSchema, PromptLabelSchema from shared |
listPrompts | Basic field definitions | .superRefine() enforcing fromUpdatedAt ≤ toUpdatedAt |
updatePromptLabels | Simple types | ParamNewLabels with .refine() blocking the "latest" label |
promptReadToolFactory | Three-field object | .refine() enforcing mutual exclusivity of label and version |
Key Files#
| File | Role |
|---|---|
web/src/features/mcp/core/define-tool.ts | Core abstraction — defineTool, schema-to-JSON-Schema conversion, wrapped handler |
web/src/features/mcp/features/prompts/tools/createChatPrompt.ts | Canonical example of the split; comment explains the union-type workaround |
web/src/features/mcp/features/prompts/tools/ | All prompt MCP tool implementations |
Authoring a New Tool#
When adding a new MCP tool:
- Write
baseSchemafirst — use only simple Zod types (z.string(),z.number(),z.array(z.object(...))) with no.refine()or.superRefine(). Avoid union types in array items. - Write
inputSchemaseparately — extend or replace fields with stricter validators from@langfuse/sharedand add all cross-field refinements. - Pass both to
defineTool. The framework handles JSON Schema generation and runtime validation wiring automatically. - If a field's domain type (e.g.,
PromptNameSchema) would produceoneOf/anyOfin JSON Schema, substitute a plainz.string()inbaseSchemaand rely oninputSchemafor the domain constraint.