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
38 changes: 38 additions & 0 deletions docs/errors/DF0043.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
outline: deep
---

# DF0043: Invalid RPC Argument

## Message

> RPC function "`{name}`" received an invalid argument at position `{index}`: `{issues}`

## Cause

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.

## Example

```ts
const greet = defineRpcFunction({
name: 'greet',
args: [v.string()],
returns: v.string(),
handler: name => `hi ${name}`,
})

// ✓ Good
await ctx.rpc.functions.greet('ada')

// ✗ Bad — a number where a string is required → DF0043 at position 0
await ctx.rpc.functions.greet(42 as never)
```

## Fix

Pass a value that satisfies the `args` schema declared for the function, or widen the schema if the value is legitimately allowed.

## Source

- [`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.
33 changes: 33 additions & 0 deletions docs/errors/DF0044.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
outline: deep
---

# DF0044: Invalid RPC Return Value

## Message

> RPC function "`{name}`" returned a value that failed its `returns` schema: `{issues}`

## Cause

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.

## Example

```ts
const count = defineRpcFunction({
name: 'count',
args: [],
returns: v.number(),
// ✗ Bad — returns a string where a number is declared → DF0044
handler: () => 'twelve' as never,
})
```

## Fix

Make the handler return a value that satisfies the `returns` schema, or relax the schema so it describes the value the handler actually produces.

## Source

- [`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.
7 changes: 5 additions & 2 deletions docs/guide/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ outline: deep

# RPC

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.
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.

## Overview

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

### Handler arguments

Handlers accept any serializable arguments. With `args` valibot schemas, arguments are validated at the boundary:
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:

```ts
defineRpcFunction({
Expand All @@ -94,6 +94,9 @@ defineRpcFunction({

Prefer a single object argument (`args: [v.object({ ... })]`) over positional args — property names are self-describing and agents/IDEs work best with object shapes.

> [!WARNING]
> Declared `args`/`returns` schemas are enforced at runtime — a call whose arguments, or a handler whose return value, fail the schema is rejected with `DF0043` / `DF0044`. Make sure each schema matches what the function actually accepts and returns; a schema stricter than reality will now reject calls that previously ran.

### Setup vs handler

Two ways to wire a handler:
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
}
},
"dependencies": {
"@standard-schema/spec": "catalog:deps",
"@valibot/to-json-schema": "catalog:deps",
"birpc": "catalog:deps",
"crossws": "catalog:deps",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import * as v from 'valibot'
import { describe, expect, it } from 'vitest'
import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from '../to-json-schema'
import { argsToJsonSchema, returnToJsonSchema } from '../to-json-schema'

describe('valibotArgsToJsonSchema', () => {
describe('argsToJsonSchema', () => {
it('returns an empty object schema when no args', () => {
const { schema, unwrapped } = valibotArgsToJsonSchema(undefined)
const { schema, unwrapped } = argsToJsonSchema(undefined)
expect(unwrapped).toBe(false)
expect(schema).toEqual({ type: 'object', properties: {} })
})

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

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

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

describe('valibotReturnToJsonSchema', () => {
describe('returnToJsonSchema', () => {
it('returns undefined when no schema is provided', () => {
expect(valibotReturnToJsonSchema(undefined)).toBeUndefined()
expect(returnToJsonSchema(undefined)).toBeUndefined()
})

it('converts a simple schema', () => {
const schema = valibotReturnToJsonSchema(v.object({ ok: v.boolean() }))
const schema = returnToJsonSchema(v.object({ ok: v.boolean() }))
expect((schema as any).type).toBe('object')
expect((schema as any).properties.ok).toMatchObject({ type: 'boolean' })
})
})

describe('non-valibot Standard Schemas', () => {
// A minimal Standard Schema from a made-up vendor (mirrors zod/arktype,
// which devframe core does not depend on) — no valibot internals.
const foreign = {
'~standard': {
version: 1 as const,
vendor: 'acme',
validate: (value: unknown) => ({ value }),
},
}

it('falls back to a permissive object per positional arg', () => {
const { schema, unwrapped } = argsToJsonSchema([foreign, foreign])
expect(unwrapped).toBe(false)
expect(schema).toMatchObject({ type: 'object', required: ['arg0', 'arg1'] })
expect((schema as any).properties.arg0).toEqual({ type: 'object', additionalProperties: true })
})

it('unwraps a single foreign arg to the permissive object', () => {
const { schema, unwrapped } = argsToJsonSchema([foreign])
expect(unwrapped).toBe(true)
expect(schema).toEqual({ type: 'object', additionalProperties: true })
})

it('falls back to a permissive object schema for returns', () => {
expect(returnToJsonSchema(foreign)).toEqual({ type: 'object', additionalProperties: true })
})
})
10 changes: 5 additions & 5 deletions packages/devframe/src/adapters/mcp/build-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc'
import type { AgentTool, DevframeDefinition, DevframeHost, DevframeNodeContext } from 'devframe/types'
import type { GenericSchema } from 'valibot'
import { homedir } from 'node:os'
import process from 'node:process'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
Expand All @@ -14,7 +14,7 @@ import { createHostContext } from 'devframe/node'
import { join } from 'pathe'
import { diagnostics } from '../../node/diagnostics'
import { formatMcpError, stringifyForMcp } from './stringify'
import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from './to-json-schema'
import { argsToJsonSchema, returnToJsonSchema } from './to-json-schema'

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

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

function parseResourceUri(uri: string): { kind: 'resource', id: string } | { kind: 'state', key: string } | { kind: 'unknown' } {
Expand Down
33 changes: 18 additions & 15 deletions packages/devframe/src/adapters/mcp/to-json-schema.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,33 @@
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { GenericSchema } from 'valibot'
import { toJsonSchema } from '@valibot/to-json-schema'

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

/**
* Convert a valibot return schema to JSON Schema.
* Convert a Standard Schema return value to JSON Schema for the agent
* surface. valibot schemas convert precisely; other Standard Schema
* vendors (zod, arktype, …) have no universal JSON Schema mapping and
* fall back to a permissive object schema.
* @internal
*/
export function valibotReturnToJsonSchema(schema: GenericSchema | undefined): unknown {
export function returnToJsonSchema(schema: StandardSchemaV1 | undefined): unknown {
if (!schema)
return undefined
try {
return toJsonSchema(schema as any)
}
catch {
return FALLBACK_OBJECT_SCHEMA
}
return safeToJsonSchema(schema)
}

/**
* Convert positional RPC args schemas to a single MCP-friendly object
* schema. When the RPC declares `args: [v.object(...)]`, unwrap the
* single-object schema directly (nicer agent UX than `{ arg0: {...} }`).
* schema. When the RPC declares `args: [object(...)]`, unwrap the single
* object schema directly (nicer agent UX than `{ arg0: {...} }`).
*
* Returns `undefined` when there are no args (the MCP SDK treats this
* as `{ type: 'object', properties: {} }`).
* @internal
*/
export function valibotArgsToJsonSchema(
args: readonly GenericSchema[] | undefined,
export function argsToJsonSchema(
args: readonly StandardSchemaV1[] | undefined,
): { schema: unknown, unwrapped: boolean } {
if (!args || args.length === 0)
return { schema: { type: 'object', properties: {} }, unwrapped: false }
Expand All @@ -48,7 +47,7 @@ export function valibotArgsToJsonSchema(
const s = safeToJsonSchema(args[i]!)
properties[key] = s
// Conservatively mark every positional arg as required — the RPC
// layer validates against valibot anyway.
// layer validates against the declared schema anyway.
required.push(key)
}

Expand All @@ -63,9 +62,13 @@ export function valibotArgsToJsonSchema(
}
}

function safeToJsonSchema(schema: GenericSchema): unknown {
function safeToJsonSchema(schema: StandardSchemaV1): unknown {
// Only valibot exposes a JSON Schema converter; other vendors degrade
// to a permissive object schema rather than throwing.
if (schema['~standard']?.vendor !== 'valibot')
return FALLBACK_OBJECT_SCHEMA
try {
return toJsonSchema(schema as any)
return toJsonSchema(schema as unknown as GenericSchema)
}
catch {
return FALLBACK_OBJECT_SCHEMA
Expand Down
3 changes: 2 additions & 1 deletion packages/devframe/src/node/host-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ export class DevframeAgentHost implements DevframeAgentHostType {
rpcName: name,
examples: agent.examples,
// Schemas are carried by the definition itself — consumers
// (e.g. the MCP adapter) convert valibot → JSON Schema on demand.
// (e.g. the MCP adapter) convert the Standard Schema → JSON Schema
// on demand.
})
}
return out
Expand Down
10 changes: 10 additions & 0 deletions packages/devframe/src/rpc/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,15 @@ export const diagnostics = defineDiagnostics({
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.`,
fix: 'Remove `snapshot: true`, or change the function type to `query`.',
},
DF0043: {
why: (p: { name: string, index: number, issues: string }) =>
`RPC function "${p.name}" received an invalid argument at position ${p.index}: ${p.issues}`,
fix: 'Pass a value that satisfies the `args` schema declared for this function.',
},
DF0044: {
why: (p: { name: string, issues: string }) =>
`RPC function "${p.name}" returned a value that failed its \`returns\` schema: ${p.issues}`,
fix: 'Make the handler return a value that satisfies the `returns` schema, or relax the schema.',
},
},
})
32 changes: 26 additions & 6 deletions packages/devframe/src/rpc/handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { RpcFunctionDefinition, RpcFunctionSetupResult, RpcFunctionType } from './types'
import { diagnostics } from './diagnostics'
import { validateRpcArgs, validateRpcReturn } from './validate-io'

export async function getRpcResolvedSetupResult<
NAME extends string,
Expand Down Expand Up @@ -58,12 +59,31 @@ export async function getRpcHandler<
definition: RpcFunctionDefinition<NAME, TYPE, ARGS, RETURN, any, any, CONTEXT>,
context: CONTEXT,
): Promise<(...args: ARGS) => RETURN> {
if (definition.handler) {
return definition.handler
let handler = definition.handler
if (!handler) {
const result = await getRpcResolvedSetupResult(definition, context)
if (!result.handler) {
throw diagnostics.DF0024({ name: definition.name })
}
handler = result.handler
}

// When `args`/`returns` Standard Schemas are declared, validate inputs
// before the handler runs and the output after it returns (guard-only —
// payloads are never rewritten). Wrapping here means every invocation
// path — local, over-the-wire, and the agent/MCP bridge — funnels
// through the same validation.
const argsSchema = definition.args
const returnSchema = definition.returns
if (!argsSchema && !returnSchema) {
return handler
}
const result = await getRpcResolvedSetupResult(definition, context)
if (!result.handler) {
throw diagnostics.DF0024({ name: definition.name })

const inner = handler
const validating = async (...args: ARGS): Promise<RETURN> => {
const validatedArgs = await validateRpcArgs(definition.name, argsSchema, args)
const output = await inner(...(validatedArgs as ARGS))
return await validateRpcReturn(definition.name, returnSchema, output) as RETURN
}
return result.handler
return validating as (...args: ARGS) => RETURN
}
1 change: 1 addition & 0 deletions packages/devframe/src/rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export * from './define'
export * from './handler'
export * from './serialization'
export * from './types'
export * from './validate-io'
export * from './validation'

/** @deprecated Import from `devframe/rpc/dump` instead. */
Expand Down
Loading
Loading