Skip to content

Commit bc5b950

Browse files
committed
feat(devframe): accept Standard Schema for RPC args/return schemas
Consumers using zod (or any other Standard Schema vendor) instead of valibot can now declare RPC args/returns directly, with the same compile-time inference behavior as today. RpcArgsSchema/RpcReturnSchema widen from valibot's GenericSchema to StandardSchemaV1 — a structural superset, so existing valibot-based code is unaffected. JSON Schema generation for agent-exposed tools now prefers a schema's own ~standard.jsonSchema converter (e.g. zod 4) before falling back to @valibot/to-json-schema, then to the existing generic object fallback.
1 parent e7a67e2 commit bc5b950

9 files changed

Lines changed: 116 additions & 45 deletions

File tree

packages/devframe/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
}
8383
},
8484
"dependencies": {
85+
"@standard-schema/spec": "catalog:deps",
8586
"@valibot/to-json-schema": "catalog:deps",
8687
"birpc": "catalog:deps",
8788
"crossws": "catalog:deps",
Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,31 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
12
import * as v from 'valibot'
23
import { describe, expect, it } from 'vitest'
3-
import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from '../to-json-schema'
4+
import { argsToJsonSchema, returnToJsonSchema } from '../to-json-schema'
45

5-
describe('valibotArgsToJsonSchema', () => {
6+
/** Minimal Standard Schema implementation that never validates, for exercising the JSON Schema dispatch. */
7+
function fakeSchema(options: { jsonSchema?: (options: { target: string }) => unknown } = {}): StandardSchemaV1 {
8+
return {
9+
'~standard': {
10+
version: 1,
11+
vendor: 'fake',
12+
validate: (value: unknown) => ({ value }),
13+
...(options.jsonSchema
14+
? { jsonSchema: { input: options.jsonSchema, output: options.jsonSchema } }
15+
: {}),
16+
},
17+
} as StandardSchemaV1
18+
}
19+
20+
describe('argsToJsonSchema', () => {
621
it('returns an empty object schema when no args', () => {
7-
const { schema, unwrapped } = valibotArgsToJsonSchema(undefined)
22+
const { schema, unwrapped } = argsToJsonSchema(undefined)
823
expect(unwrapped).toBe(false)
924
expect(schema).toEqual({ type: 'object', properties: {} })
1025
})
1126

1227
it('wraps multiple positional args under arg0/arg1/...', () => {
13-
const { schema, unwrapped } = valibotArgsToJsonSchema([v.string(), v.number()])
28+
const { schema, unwrapped } = argsToJsonSchema([v.string(), v.number()])
1429
expect(unwrapped).toBe(false)
1530
expect(schema).toMatchObject({
1631
type: 'object',
@@ -23,7 +38,7 @@ describe('valibotArgsToJsonSchema', () => {
2338
})
2439

2540
it('unwraps a single object schema for nicer agent UX', () => {
26-
const { schema, unwrapped } = valibotArgsToJsonSchema([
41+
const { schema, unwrapped } = argsToJsonSchema([
2742
v.object({ name: v.string(), age: v.number() }),
2843
])
2944
expect(unwrapped).toBe(true)
@@ -34,20 +49,41 @@ describe('valibotArgsToJsonSchema', () => {
3449
})
3550

3651
it('keeps arg0 shape when the single arg is a primitive', () => {
37-
const { schema, unwrapped } = valibotArgsToJsonSchema([v.string()])
52+
const { schema, unwrapped } = argsToJsonSchema([v.string()])
3853
expect(unwrapped).toBe(false)
3954
expect(schema).toMatchObject({ type: 'object', required: ['arg0'] })
4055
})
4156
})
4257

43-
describe('valibotReturnToJsonSchema', () => {
58+
describe('returnToJsonSchema', () => {
4459
it('returns undefined when no schema is provided', () => {
45-
expect(valibotReturnToJsonSchema(undefined)).toBeUndefined()
60+
expect(returnToJsonSchema(undefined)).toBeUndefined()
4661
})
4762

48-
it('converts a simple schema', () => {
49-
const schema = valibotReturnToJsonSchema(v.object({ ok: v.boolean() }))
63+
it('converts a simple valibot schema', () => {
64+
const schema = returnToJsonSchema(v.object({ ok: v.boolean() }))
5065
expect((schema as any).type).toBe('object')
5166
expect((schema as any).properties.ok).toMatchObject({ type: 'boolean' })
5267
})
68+
69+
it('uses the schema\'s own Standard Schema JSON Schema converter when present', () => {
70+
const schema = returnToJsonSchema(fakeSchema({
71+
jsonSchema: () => ({ type: 'string', format: 'email' }),
72+
}))
73+
expect(schema).toEqual({ type: 'string', format: 'email' })
74+
})
75+
76+
it('falls back to the generic object schema when no converter is available', () => {
77+
const schema = returnToJsonSchema(fakeSchema())
78+
expect(schema).toEqual({ type: 'object', additionalProperties: true })
79+
})
80+
81+
it('falls back to the generic object schema when the converter throws', () => {
82+
const schema = returnToJsonSchema(fakeSchema({
83+
jsonSchema: () => {
84+
throw new Error('unsupported')
85+
},
86+
}))
87+
expect(schema).toEqual({ type: 'object', additionalProperties: true })
88+
})
5389
})

packages/devframe/src/adapters/mcp/build-server.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
12
import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc'
23
import type { AgentTool, DevframeDefinition, DevframeHost, DevframeNodeContext } from 'devframe/types'
3-
import type { GenericSchema } from 'valibot'
44
import { homedir } from 'node:os'
55
import process from 'node:process'
66
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
@@ -14,7 +14,7 @@ import { createHostContext } from 'devframe/node'
1414
import { join } from 'pathe'
1515
import { diagnostics } from '../../node/diagnostics'
1616
import { formatMcpError, stringifyForMcp } from './stringify'
17-
import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from './to-json-schema'
17+
import { argsToJsonSchema, returnToJsonSchema } from './to-json-schema'
1818

1919
export interface CreateMcpServerOptions {
2020
/**
@@ -276,8 +276,8 @@ function computeInputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown
276276
const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext<DevframeNodeContext> | undefined
277277
if (!def)
278278
return { type: 'object', properties: {} }
279-
const args = def.args as readonly GenericSchema[] | undefined
280-
return valibotArgsToJsonSchema(args).schema
279+
const args = def.args as readonly StandardSchemaV1[] | undefined
280+
return argsToJsonSchema(args).schema
281281
}
282282

283283
function computeOutputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown {
@@ -286,7 +286,7 @@ function computeOutputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown
286286
const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext<DevframeNodeContext> | undefined
287287
if (!def)
288288
return undefined
289-
return valibotReturnToJsonSchema(def.returns as GenericSchema | undefined)
289+
return returnToJsonSchema(def.returns as StandardSchemaV1 | undefined)
290290
}
291291

292292
function parseResourceUri(uri: string): { kind: 'resource', id: string } | { kind: 'state', key: string } | { kind: 'unknown' } {

packages/devframe/src/adapters/mcp/to-json-schema.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
1-
import type { GenericSchema } from 'valibot'
1+
import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'
22
import { toJsonSchema } from '@valibot/to-json-schema'
33

44
const FALLBACK_OBJECT_SCHEMA = Object.freeze({ type: 'object', additionalProperties: true })
55

66
/**
7-
* Convert a valibot return schema to JSON Schema.
7+
* Convert a Standard Schema return schema to JSON Schema.
88
* @internal
99
*/
10-
export function valibotReturnToJsonSchema(schema: GenericSchema | undefined): unknown {
10+
export function returnToJsonSchema(schema: StandardSchemaV1 | undefined): unknown {
1111
if (!schema)
1212
return undefined
13-
try {
14-
return toJsonSchema(schema as any)
15-
}
16-
catch {
17-
return FALLBACK_OBJECT_SCHEMA
18-
}
13+
return safeToJsonSchema(schema)
1914
}
2015

2116
/**
@@ -27,8 +22,8 @@ export function valibotReturnToJsonSchema(schema: GenericSchema | undefined): un
2722
* as `{ type: 'object', properties: {} }`).
2823
* @internal
2924
*/
30-
export function valibotArgsToJsonSchema(
31-
args: readonly GenericSchema[] | undefined,
25+
export function argsToJsonSchema(
26+
args: readonly StandardSchemaV1[] | undefined,
3227
): { schema: unknown, unwrapped: boolean } {
3328
if (!args || args.length === 0)
3429
return { schema: { type: 'object', properties: {} }, unwrapped: false }
@@ -47,8 +42,8 @@ export function valibotArgsToJsonSchema(
4742
const key = `arg${i}`
4843
const s = safeToJsonSchema(args[i]!)
4944
properties[key] = s
50-
// Conservatively mark every positional arg as required — the RPC
51-
// layer validates against valibot anyway.
45+
// Positional args carry no optionality signal at this layer, so every
46+
// one is conservatively marked required.
5247
required.push(key)
5348
}
5449

@@ -63,8 +58,13 @@ export function valibotArgsToJsonSchema(
6358
}
6459
}
6560

66-
function safeToJsonSchema(schema: GenericSchema): unknown {
61+
type StandardSchemaProps = StandardSchemaV1['~standard'] & Partial<StandardJSONSchemaV1['~standard']>
62+
63+
function safeToJsonSchema(schema: StandardSchemaV1): unknown {
64+
const standard = schema['~standard'] as StandardSchemaProps
6765
try {
66+
if (standard.jsonSchema)
67+
return standard.jsonSchema.input({ target: 'draft-2020-12' })
6868
return toJsonSchema(schema as any)
6969
}
7070
catch {

packages/devframe/src/rpc/types.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable unused-imports/no-unused-vars */
2+
import type { StandardSchemaV1 } from '@standard-schema/spec'
23
import type {
34
RpcDefinitionsToFunctions,
45
RpcFunctionDefinitionToFunction,
@@ -8,6 +9,18 @@ import * as v from 'valibot'
89
import { describe, it } from 'vitest'
910
import { defineRpcFunction } from '.'
1011

12+
/** Minimal Standard Schema implementation, for asserting inference works for any vendor. */
13+
function fakeSchema<Input>(): StandardSchemaV1<Input> {
14+
return {
15+
'~standard': {
16+
version: 1,
17+
vendor: 'fake',
18+
validate: (value: unknown) => ({ value: value as Input }),
19+
types: { input: undefined as Input, output: undefined as Input },
20+
},
21+
}
22+
}
23+
1124
describe('rpcFunctionDefinitionToFunction', () => {
1225
it('should infer types from generic parameters when no schemas', () => {
1326
const fn = defineRpcFunction({
@@ -74,6 +87,20 @@ describe('rpcFunctionDefinitionToFunction', () => {
7487
type Result = RpcFunctionDefinitionToFunction<typeof fn>
7588
type _Test = AssertEqual<Result, (arg_0: { id: string }) => string[]>
7689
})
90+
91+
it('should infer types from a non-valibot Standard Schema', () => {
92+
const fn = defineRpcFunction({
93+
name: 'standardSchema',
94+
args: [fakeSchema<string>(), fakeSchema<number>()],
95+
returns: fakeSchema<boolean>(),
96+
handler: (a, b) => {
97+
return a.length > b
98+
},
99+
})
100+
101+
type Result = RpcFunctionDefinitionToFunction<typeof fn>
102+
type _Test = AssertEqual<Result, (arg_0: string, arg_1: number) => boolean>
103+
})
77104
})
78105

79106
describe('rpcDefinitionsToFunctions', () => {

packages/devframe/src/rpc/types.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GenericSchema } from 'valibot'
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
22
import type { InferArgsType, InferReturnType } from './utils'
33

44
export type { BirpcFn, BirpcReturn } from 'birpc'
@@ -93,10 +93,10 @@ export interface RpcFunctionSetupResult<
9393
dump?: RpcDumpDefinition<ARGS, RETURN>
9494
}
9595

96-
/** Valibot schema array for validating function arguments */
97-
export type RpcArgsSchema = readonly GenericSchema[]
98-
/** Valibot schema for validating function return value */
99-
export type RpcReturnSchema = GenericSchema
96+
/** Standard Schema array (valibot, zod, …) for validating function arguments */
97+
export type RpcArgsSchema = readonly StandardSchemaV1[]
98+
/** Standard Schema (valibot, zod, …) for validating function return value */
99+
export type RpcReturnSchema = StandardSchemaV1
100100

101101
/**
102102
* Serialized representation of a thrown value in a dump record.
@@ -233,9 +233,9 @@ export type RpcFunctionDefinition<
233233
type?: TYPE
234234
/** Whether the function results should be cached */
235235
cacheable?: boolean
236-
/** Valibot schema array for validating function arguments */
236+
/** Standard Schema array (valibot, zod, …) for validating function arguments */
237237
args?: AS
238-
/** Valibot schema for validating function return value */
238+
/** Standard Schema (valibot, zod, …) for validating function return value */
239239
returns?: RS
240240
/**
241241
* Declares whether this function's args/return are JSON-serializable
@@ -281,9 +281,9 @@ export type RpcFunctionDefinition<
281281
type?: TYPE
282282
/** Whether the function results should be cached */
283283
cacheable?: boolean
284-
/** Valibot schema array for validating function arguments */
284+
/** Standard Schema array (valibot, zod, …) for validating function arguments */
285285
args: AS
286-
/** Valibot schema for validating function return value */
286+
/** Standard Schema (valibot, zod, …) for validating function return value */
287287
returns: RS
288288
/**
289289
* Declares whether this function's args/return are JSON-serializable

packages/devframe/src/rpc/utils.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
1-
import type { GenericSchema, InferInput } from 'valibot'
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
22
import type { RpcArgsSchema, RpcReturnSchema } from './types'
33

44
/** Type-level assertion that two types are equal */
55
export type AssertEqual<X, Y>
66
= (<T>() => T extends X ? 1 : 2) extends
77
(<T>() => T extends Y ? 1 : 2) ? true : never
88

9-
/** Infers TypeScript tuple type from Valibot schema array */
9+
/** Infers TypeScript tuple type from a Standard Schema array */
1010
export type InferArgsType<S extends RpcArgsSchema | undefined>
1111
= S extends readonly [] ? []
1212
: S extends readonly [infer H, ...infer T]
13-
? H extends GenericSchema
14-
? T extends readonly GenericSchema[]
15-
? [InferInput<H>, ...InferArgsType<T>]
13+
? H extends StandardSchemaV1
14+
? T extends readonly StandardSchemaV1[]
15+
? [StandardSchemaV1.InferInput<H>, ...InferArgsType<T>]
1616
: never
1717
: never
1818
: never
1919

20-
/** Infers TypeScript return type from Valibot return schema */
20+
/** Infers TypeScript return type from a Standard Schema return schema */
2121
export type InferReturnType<S extends RpcReturnSchema | undefined>
2222
= S extends RpcReturnSchema
23-
? InferInput<S>
23+
? StandardSchemaV1.InferInput<S>
2424
: void

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ catalogs:
5858
deps:
5959
'@json-render/core': ^0.19.0
6060
'@modelcontextprotocol/sdk': ^1.30.0
61+
'@standard-schema/spec': ^1.1.0
6162
'@valibot/to-json-schema': ^1.7.1
6263
birpc: ^4.0.0
6364
cac: ^7.0.0

0 commit comments

Comments
 (0)