Skip to content

Commit b10868d

Browse files
committed
feat(rpc): accept any Standard Schema for RPC args/returns and validate at runtime
Widen `args`/`returns` from valibot's `GenericSchema` to the `StandardSchemaV1` interface so valibot, zod, arktype, and any other Standard Schema validator work interchangeably, and infer handler types from the schema's Standard Schema input types. Every invocation path (local, over-the-wire, agent/MCP) now validates declared `args`/`returns` at the boundary via `getRpcHandler`, throwing coded diagnostics DF0043 / DF0044 on failure. Validation is guard-only: payloads are never rewritten, so schemas describing a subset of an object don't strip the sender's extra fields. The MCP JSON-schema surface keeps valibot's converter for precise schemas and degrades non-valibot vendors to a permissive object schema.
1 parent e7a67e2 commit b10868d

20 files changed

Lines changed: 451 additions & 55 deletions

File tree

docs/errors/DF0043.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0043: Invalid RPC Argument
6+
7+
## Message
8+
9+
> RPC function "`{name}`" received an invalid argument at position `{index}`: `{issues}`
10+
11+
## Cause
12+
13+
When an RPC function declares `args` schemas, each incoming argument is validated against its positional [Standard Schema](https://standardschema.dev/) (valibot, zod, arktype, …) before the handler runs — on every path: local calls, over-the-wire calls, and the agent/MCP bridge. The argument at `{index}` failed that schema. Validation guards the payload without rewriting it, so extra object fields the schema doesn't mention still reach the handler.
14+
15+
## Example
16+
17+
```ts
18+
const greet = defineRpcFunction({
19+
name: 'greet',
20+
args: [v.string()],
21+
returns: v.string(),
22+
handler: name => `hi ${name}`,
23+
})
24+
25+
// ✓ Good
26+
await ctx.rpc.functions.greet('ada')
27+
28+
// ✗ Bad — a number where a string is required → DF0043 at position 0
29+
await ctx.rpc.functions.greet(42 as never)
30+
```
31+
32+
## Fix
33+
34+
Pass a value that satisfies the `args` schema declared for the function, or widen the schema if the value is legitimately allowed.
35+
36+
## Source
37+
38+
- [`packages/devframe/src/rpc/validate-io.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/rpc/validate-io.ts)`validateRpcArgs()` throws `DF0043` on the first argument that fails its declared schema.

docs/errors/DF0044.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0044: Invalid RPC Return Value
6+
7+
## Message
8+
9+
> RPC function "`{name}`" returned a value that failed its `returns` schema: `{issues}`
10+
11+
## Cause
12+
13+
When an RPC function declares a `returns` schema, the handler's resolved value is validated against that [Standard Schema](https://standardschema.dev/) (valibot, zod, arktype, …) before it is sent back to the caller. The value the handler produced does not satisfy the schema — a bug in the handler or a schema that is narrower than the real result. Validation guards the payload without rewriting it, so a value that merely carries extra object fields is accepted.
14+
15+
## Example
16+
17+
```ts
18+
const count = defineRpcFunction({
19+
name: 'count',
20+
args: [],
21+
returns: v.number(),
22+
// ✗ Bad — returns a string where a number is declared → DF0044
23+
handler: () => 'twelve' as never,
24+
})
25+
```
26+
27+
## Fix
28+
29+
Make the handler return a value that satisfies the `returns` schema, or relax the schema so it describes the value the handler actually produces.
30+
31+
## Source
32+
33+
- [`packages/devframe/src/rpc/validate-io.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/rpc/validate-io.ts)`validateRpcReturn()` throws `DF0044` when the handler's resolved value fails its declared schema.

docs/guide/rpc.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ outline: deep
44

55
# RPC
66

7-
Devframe's RPC layer is type-safe bidirectional communication between your server (Node.js) and client (browser), built on [`birpc`](https://github.com/antfu/birpc) and validated at runtime with [`valibot`](https://valibot.dev/). In dev mode it runs over WebSocket; in build / SPA mode it serves a pre-computed static dump so the client still works offline.
7+
Devframe's RPC layer is type-safe bidirectional communication between your server (Node.js) and client (browser), built on [`birpc`](https://github.com/antfu/birpc) and validated at runtime against any [Standard Schema](https://standardschema.dev/) validator — valibot, zod, arktype, and others all work. In dev mode it runs over WebSocket; in build / SPA mode it serves a pre-computed static dump so the client still works offline.
88

99
## Overview
1010

@@ -75,7 +75,7 @@ Use `static` for data collected once during `setup` and shipped to read-only sta
7575

7676
### Handler arguments
7777

78-
Handlers accept any serializable arguments. With `args` valibot schemas, arguments are validated at the boundary:
78+
Handlers accept any serializable arguments. Declare `args` schemas — any [Standard Schema](https://standardschema.dev/) validator, valibot below — and each argument is validated at the boundary before the handler runs; a mismatch is rejected with a coded diagnostic. Validation guards the payload without rewriting it, so extra object fields the schema doesn't mention still reach the handler:
7979

8080
```ts
8181
defineRpcFunction({

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: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import * as v from 'valibot'
22
import { describe, expect, it } from 'vitest'
3-
import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from '../to-json-schema'
3+
import { argsToJsonSchema, returnToJsonSchema } from '../to-json-schema'
44

5-
describe('valibotArgsToJsonSchema', () => {
5+
describe('argsToJsonSchema', () => {
66
it('returns an empty object schema when no args', () => {
7-
const { schema, unwrapped } = valibotArgsToJsonSchema(undefined)
7+
const { schema, unwrapped } = argsToJsonSchema(undefined)
88
expect(unwrapped).toBe(false)
99
expect(schema).toEqual({ type: 'object', properties: {} })
1010
})
1111

1212
it('wraps multiple positional args under arg0/arg1/...', () => {
13-
const { schema, unwrapped } = valibotArgsToJsonSchema([v.string(), v.number()])
13+
const { schema, unwrapped } = argsToJsonSchema([v.string(), v.number()])
1414
expect(unwrapped).toBe(false)
1515
expect(schema).toMatchObject({
1616
type: 'object',
@@ -23,7 +23,7 @@ describe('valibotArgsToJsonSchema', () => {
2323
})
2424

2525
it('unwraps a single object schema for nicer agent UX', () => {
26-
const { schema, unwrapped } = valibotArgsToJsonSchema([
26+
const { schema, unwrapped } = argsToJsonSchema([
2727
v.object({ name: v.string(), age: v.number() }),
2828
])
2929
expect(unwrapped).toBe(true)
@@ -34,20 +34,49 @@ describe('valibotArgsToJsonSchema', () => {
3434
})
3535

3636
it('keeps arg0 shape when the single arg is a primitive', () => {
37-
const { schema, unwrapped } = valibotArgsToJsonSchema([v.string()])
37+
const { schema, unwrapped } = argsToJsonSchema([v.string()])
3838
expect(unwrapped).toBe(false)
3939
expect(schema).toMatchObject({ type: 'object', required: ['arg0'] })
4040
})
4141
})
4242

43-
describe('valibotReturnToJsonSchema', () => {
43+
describe('returnToJsonSchema', () => {
4444
it('returns undefined when no schema is provided', () => {
45-
expect(valibotReturnToJsonSchema(undefined)).toBeUndefined()
45+
expect(returnToJsonSchema(undefined)).toBeUndefined()
4646
})
4747

4848
it('converts a simple schema', () => {
49-
const schema = valibotReturnToJsonSchema(v.object({ ok: v.boolean() }))
49+
const schema = returnToJsonSchema(v.object({ ok: v.boolean() }))
5050
expect((schema as any).type).toBe('object')
5151
expect((schema as any).properties.ok).toMatchObject({ type: 'boolean' })
5252
})
5353
})
54+
55+
describe('non-valibot Standard Schemas', () => {
56+
// A minimal Standard Schema from a made-up vendor (mirrors zod/arktype,
57+
// which devframe core does not depend on) — no valibot internals.
58+
const foreign = {
59+
'~standard': {
60+
version: 1 as const,
61+
vendor: 'acme',
62+
validate: (value: unknown) => ({ value }),
63+
},
64+
}
65+
66+
it('falls back to a permissive object per positional arg', () => {
67+
const { schema, unwrapped } = argsToJsonSchema([foreign, foreign])
68+
expect(unwrapped).toBe(false)
69+
expect(schema).toMatchObject({ type: 'object', required: ['arg0', 'arg1'] })
70+
expect((schema as any).properties.arg0).toEqual({ type: 'object', additionalProperties: true })
71+
})
72+
73+
it('unwraps a single foreign arg to the permissive object', () => {
74+
const { schema, unwrapped } = argsToJsonSchema([foreign])
75+
expect(unwrapped).toBe(true)
76+
expect(schema).toEqual({ type: 'object', additionalProperties: true })
77+
})
78+
79+
it('falls back to a permissive object schema for returns', () => {
80+
expect(returnToJsonSchema(foreign)).toEqual({ type: 'object', additionalProperties: true })
81+
})
82+
})

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: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,33 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
12
import type { GenericSchema } from 'valibot'
23
import { toJsonSchema } from '@valibot/to-json-schema'
34

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

67
/**
7-
* Convert a valibot return schema to JSON Schema.
8+
* Convert a Standard Schema return value to JSON Schema for the agent
9+
* surface. valibot schemas convert precisely; other Standard Schema
10+
* vendors (zod, arktype, …) have no universal JSON Schema mapping and
11+
* fall back to a permissive object schema.
812
* @internal
913
*/
10-
export function valibotReturnToJsonSchema(schema: GenericSchema | undefined): unknown {
14+
export function returnToJsonSchema(schema: StandardSchemaV1 | undefined): unknown {
1115
if (!schema)
1216
return undefined
13-
try {
14-
return toJsonSchema(schema as any)
15-
}
16-
catch {
17-
return FALLBACK_OBJECT_SCHEMA
18-
}
17+
return safeToJsonSchema(schema)
1918
}
2019

2120
/**
2221
* Convert positional RPC args schemas to a single MCP-friendly object
23-
* schema. When the RPC declares `args: [v.object(...)]`, unwrap the
24-
* single-object schema directly (nicer agent UX than `{ arg0: {...} }`).
22+
* schema. When the RPC declares `args: [object(...)]`, unwrap the single
23+
* object schema directly (nicer agent UX than `{ arg0: {...} }`).
2524
*
2625
* Returns `undefined` when there are no args (the MCP SDK treats this
2726
* as `{ type: 'object', properties: {} }`).
2827
* @internal
2928
*/
30-
export function valibotArgsToJsonSchema(
31-
args: readonly GenericSchema[] | undefined,
29+
export function argsToJsonSchema(
30+
args: readonly StandardSchemaV1[] | undefined,
3231
): { schema: unknown, unwrapped: boolean } {
3332
if (!args || args.length === 0)
3433
return { schema: { type: 'object', properties: {} }, unwrapped: false }
@@ -48,7 +47,7 @@ export function valibotArgsToJsonSchema(
4847
const s = safeToJsonSchema(args[i]!)
4948
properties[key] = s
5049
// Conservatively mark every positional arg as required — the RPC
51-
// layer validates against valibot anyway.
50+
// layer validates against the declared schema anyway.
5251
required.push(key)
5352
}
5453

@@ -63,9 +62,13 @@ export function valibotArgsToJsonSchema(
6362
}
6463
}
6564

66-
function safeToJsonSchema(schema: GenericSchema): unknown {
65+
function safeToJsonSchema(schema: StandardSchemaV1): unknown {
66+
// Only valibot exposes a JSON Schema converter; other vendors degrade
67+
// to a permissive object schema rather than throwing.
68+
if (schema['~standard']?.vendor !== 'valibot')
69+
return FALLBACK_OBJECT_SCHEMA
6770
try {
68-
return toJsonSchema(schema as any)
71+
return toJsonSchema(schema as unknown as GenericSchema)
6972
}
7073
catch {
7174
return FALLBACK_OBJECT_SCHEMA

packages/devframe/src/node/host-agent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ export class DevframeAgentHost implements DevframeAgentHostType {
199199
rpcName: name,
200200
examples: agent.examples,
201201
// Schemas are carried by the definition itself — consumers
202-
// (e.g. the MCP adapter) convert valibot → JSON Schema on demand.
202+
// (e.g. the MCP adapter) convert the Standard Schema → JSON Schema
203+
// on demand.
203204
})
204205
}
205206
return out

packages/devframe/src/rpc/diagnostics.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,15 @@ export const diagnostics = defineDiagnostics({
4141
why: (p: { name: string, type: string }) => `Function "${p.name}" with type "${p.type}" cannot use \`snapshot: true\`. Only "query" functions support this sugar; "static" functions have equivalent default behavior already.`,
4242
fix: 'Remove `snapshot: true`, or change the function type to `query`.',
4343
},
44+
DF0043: {
45+
why: (p: { name: string, index: number, issues: string }) =>
46+
`RPC function "${p.name}" received an invalid argument at position ${p.index}: ${p.issues}`,
47+
fix: 'Pass a value that satisfies the `args` schema declared for this function.',
48+
},
49+
DF0044: {
50+
why: (p: { name: string, issues: string }) =>
51+
`RPC function "${p.name}" returned a value that failed its \`returns\` schema: ${p.issues}`,
52+
fix: 'Make the handler return a value that satisfies the `returns` schema, or relax the schema.',
53+
},
4454
},
4555
})

packages/devframe/src/rpc/handler.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { RpcFunctionDefinition, RpcFunctionSetupResult, RpcFunctionType } from './types'
22
import { diagnostics } from './diagnostics'
3+
import { validateRpcArgs, validateRpcReturn } from './validate-io'
34

45
export async function getRpcResolvedSetupResult<
56
NAME extends string,
@@ -58,12 +59,31 @@ export async function getRpcHandler<
5859
definition: RpcFunctionDefinition<NAME, TYPE, ARGS, RETURN, any, any, CONTEXT>,
5960
context: CONTEXT,
6061
): Promise<(...args: ARGS) => RETURN> {
61-
if (definition.handler) {
62-
return definition.handler
62+
let handler = definition.handler
63+
if (!handler) {
64+
const result = await getRpcResolvedSetupResult(definition, context)
65+
if (!result.handler) {
66+
throw diagnostics.DF0024({ name: definition.name })
67+
}
68+
handler = result.handler
69+
}
70+
71+
// When `args`/`returns` Standard Schemas are declared, validate inputs
72+
// before the handler runs and the output after it returns (guard-only —
73+
// payloads are never rewritten). Wrapping here means every invocation
74+
// path — local, over-the-wire, and the agent/MCP bridge — funnels
75+
// through the same validation.
76+
const argsSchema = definition.args
77+
const returnSchema = definition.returns
78+
if (!argsSchema && !returnSchema) {
79+
return handler
6380
}
64-
const result = await getRpcResolvedSetupResult(definition, context)
65-
if (!result.handler) {
66-
throw diagnostics.DF0024({ name: definition.name })
81+
82+
const inner = handler
83+
const validating = async (...args: ARGS): Promise<RETURN> => {
84+
const validatedArgs = await validateRpcArgs(definition.name, argsSchema, args)
85+
const output = await inner(...(validatedArgs as ARGS))
86+
return await validateRpcReturn(definition.name, returnSchema, output) as RETURN
6787
}
68-
return result.handler
88+
return validating as (...args: ARGS) => RETURN
6989
}

0 commit comments

Comments
 (0)