Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-walls-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'incur': patch
---

Fixed HTTP and MCP command input validation to return standard validation field errors for object-shaped inputs.
70 changes: 70 additions & 0 deletions src/Cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'

import * as Command from './internal/command.js'

const originalIsTTY = process.stdout.isTTY
beforeAll(() => {
;(process.stdout as any).isTTY = false
Expand Down Expand Up @@ -4051,6 +4053,74 @@ describe('--filter-output', () => {
})
})

describe('Command.execute', () => {
test.each([
{
name: 'split',
command: { options: z.object({ name: z.string() }), run: () => ({ ok: true }) },
inputOptions: { name: 123 },
path: 'name',
parseMode: 'split' as const,
},
{
name: 'flat',
command: { args: z.object({ id: z.string() }), run: () => ({ ok: true }) },
inputOptions: { id: 123 },
path: 'id',
parseMode: 'flat' as const,
},
])('$name mode returns validation fieldErrors for invalid command input', async (c) => {
const result = await Command.execute(c.command, {
agent: true,
argv: [],
format: 'json',
formatExplicit: false,
inputOptions: c.inputOptions,
name: 'test',
parseMode: c.parseMode,
path: 'users',
version: undefined,
})

expect(result).toMatchObject({
ok: false,
error: {
code: 'VALIDATION_ERROR',
fieldErrors: [
{
code: 'invalid_type',
missing: false,
path: c.path,
},
],
},
})
})

test('does not normalize handler-thrown Zod errors as command input', async () => {
const result = await Command.execute(
{
run() {
z.object({ name: z.string() }).parse({ name: 123 })
},
},
{
agent: true,
argv: [],
format: 'json',
formatExplicit: false,
inputOptions: {},
name: 'test',
path: 'users',
version: undefined,
},
)

expect(result).toMatchObject({ ok: false, error: { code: 'UNKNOWN' } })
expect(result).not.toHaveProperty('error.fieldErrors')
})
})

async function fetchJson(cli: Cli.Cli<any, any, any>, req: Request) {
const res = await cli.fetch(req)
const body = await res.json()
Expand Down
2 changes: 1 addition & 1 deletion src/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ function setOption(
}

/** Wraps zod schema.parse(), converting ZodError to ValidationError. */
function zodParse(schema: z.ZodObject<any>, data: Record<string, unknown>) {
export function zodParse(schema: z.ZodObject<any>, data: Record<string, unknown>) {
try {
return schema.parse(data)
} catch (err: any) {
Expand Down
6 changes: 3 additions & 3 deletions src/internal/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ export async function execute(command: any, options: execute.Options): Promise<e
// HTTP mode: positional args from URL path segments, options from body/query
const parsed = Parser.parse(argv, { args: command.args })
args = parsed.args
parsedOptions = command.options ? command.options.parse(inputOptions) : {}
parsedOptions = command.options ? Parser.zodParse(command.options, inputOptions) : {}
} else {
// MCP mode: all params come from inputOptions, split into args vs options
const split = splitParams(inputOptions, command)
args = command.args ? command.args.parse(split.args) : {}
parsedOptions = command.options ? command.options.parse(split.options) : {}
args = command.args ? Parser.zodParse(command.args, split.args) : {}
parsedOptions = command.options ? Parser.zodParse(command.options, split.options) : {}
}

// Parse env
Expand Down