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
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
56 changes: 46 additions & 10 deletions packages/devframe/src/adapters/mcp/__tests__/to-json-schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import type { StandardSchemaV1 } from '@standard-schema/spec'
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', () => {
/** Minimal Standard Schema implementation that never validates, for exercising the JSON Schema dispatch. */
function fakeSchema(options: { jsonSchema?: (options: { target: string }) => unknown } = {}): StandardSchemaV1 {
return {
'~standard': {
version: 1,
vendor: 'fake',
validate: (value: unknown) => ({ value }),
...(options.jsonSchema
? { jsonSchema: { input: options.jsonSchema, output: options.jsonSchema } }
: {}),
},
} as StandardSchemaV1
}

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 +38,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 +49,41 @@ 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() }))
it('converts a simple valibot schema', () => {
const schema = returnToJsonSchema(v.object({ ok: v.boolean() }))
expect((schema as any).type).toBe('object')
expect((schema as any).properties.ok).toMatchObject({ type: 'boolean' })
})

it('uses the schema\'s own Standard Schema JSON Schema converter when present', () => {
const schema = returnToJsonSchema(fakeSchema({
jsonSchema: () => ({ type: 'string', format: 'email' }),
}))
expect(schema).toEqual({ type: 'string', format: 'email' })
})

it('falls back to the generic object schema when no converter is available', () => {
const schema = returnToJsonSchema(fakeSchema())
expect(schema).toEqual({ type: 'object', additionalProperties: true })
})

it('falls back to the generic object schema when the converter throws', () => {
const schema = returnToJsonSchema(fakeSchema({
jsonSchema: () => {
throw new Error('unsupported')
},
}))
expect(schema).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
28 changes: 14 additions & 14 deletions packages/devframe/src/adapters/mcp/to-json-schema.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import type { GenericSchema } from 'valibot'
import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'
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 schema to JSON 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)
}
Comment on lines +10 to 14

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept it identical to match the typing of valibot and prevent any breaking changes but if we want to cleanly support validation I can switch to output


/**
Expand All @@ -27,8 +22,8 @@ export function valibotReturnToJsonSchema(schema: GenericSchema | undefined): un
* 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 @@ -47,8 +42,8 @@ export function valibotArgsToJsonSchema(
const key = `arg${i}`
const s = safeToJsonSchema(args[i]!)
properties[key] = s
// Conservatively mark every positional arg as required — the RPC
// layer validates against valibot anyway.
// Positional args carry no optionality signal at this layer, so every
// one is conservatively marked required.
required.push(key)
}

Expand All @@ -63,8 +58,13 @@ export function valibotArgsToJsonSchema(
}
}

function safeToJsonSchema(schema: GenericSchema): unknown {
type StandardSchemaProps = StandardSchemaV1['~standard'] & Partial<StandardJSONSchemaV1['~standard']>

function safeToJsonSchema(schema: StandardSchemaV1): unknown {
const standard = schema['~standard'] as StandardSchemaProps
try {
if (standard.jsonSchema)
return standard.jsonSchema.input({ target: 'draft-2020-12' })
return toJsonSchema(schema as any)
}
catch {
Expand Down
27 changes: 27 additions & 0 deletions packages/devframe/src/rpc/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable unused-imports/no-unused-vars */
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type {
RpcDefinitionsToFunctions,
RpcFunctionDefinitionToFunction,
Expand All @@ -8,6 +9,18 @@ import * as v from 'valibot'
import { describe, it } from 'vitest'
import { defineRpcFunction } from '.'

/** Minimal Standard Schema implementation, for asserting inference works for any vendor. */
function fakeSchema<Input>(): StandardSchemaV1<Input> {
return {
'~standard': {
version: 1,
vendor: 'fake',
validate: (value: unknown) => ({ value: value as Input }),
types: { input: undefined as Input, output: undefined as Input },
},
}
}

describe('rpcFunctionDefinitionToFunction', () => {
it('should infer types from generic parameters when no schemas', () => {
const fn = defineRpcFunction({
Expand Down Expand Up @@ -74,6 +87,20 @@ describe('rpcFunctionDefinitionToFunction', () => {
type Result = RpcFunctionDefinitionToFunction<typeof fn>
type _Test = AssertEqual<Result, (arg_0: { id: string }) => string[]>
})

it('should infer types from a non-valibot Standard Schema', () => {
const fn = defineRpcFunction({
name: 'standardSchema',
args: [fakeSchema<string>(), fakeSchema<number>()],
returns: fakeSchema<boolean>(),
handler: (a, b) => {
return a.length > b
},
})

type Result = RpcFunctionDefinitionToFunction<typeof fn>
type _Test = AssertEqual<Result, (arg_0: string, arg_1: number) => boolean>
})
})

describe('rpcDefinitionsToFunctions', () => {
Expand Down
18 changes: 9 additions & 9 deletions packages/devframe/src/rpc/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { GenericSchema } from 'valibot'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { InferArgsType, InferReturnType } from './utils'

export type { BirpcFn, BirpcReturn } from 'birpc'
Expand Down Expand Up @@ -93,10 +93,10 @@ export interface RpcFunctionSetupResult<
dump?: RpcDumpDefinition<ARGS, RETURN>
}

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

/**
* Serialized representation of a thrown value in a dump record.
Expand Down Expand Up @@ -233,9 +233,9 @@ export type RpcFunctionDefinition<
type?: TYPE
/** Whether the function results should be cached */
cacheable?: boolean
/** Valibot schema array for validating function arguments */
/** Standard Schema array (valibot, zod, …) for validating function arguments */
args?: AS
/** Valibot schema for validating function return value */
/** Standard Schema (valibot, zod, …) for validating function return value */
returns?: RS
/**
* Declares whether this function's args/return are JSON-serializable
Expand Down Expand Up @@ -281,9 +281,9 @@ export type RpcFunctionDefinition<
type?: TYPE
/** Whether the function results should be cached */
cacheable?: boolean
/** Valibot schema array for validating function arguments */
/** Standard Schema array (valibot, zod, …) for validating function arguments */
args: AS
/** Valibot schema for validating function return value */
/** Standard Schema (valibot, zod, …) for validating function return value */
returns: RS
/**
* Declares whether this function's args/return are JSON-serializable
Expand Down
14 changes: 7 additions & 7 deletions packages/devframe/src/rpc/utils.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import type { GenericSchema, InferInput } from 'valibot'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { RpcArgsSchema, RpcReturnSchema } from './types'

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

/** Infers TypeScript tuple type from Valibot schema array */
/** Infers TypeScript tuple type from a Standard Schema array */
export type InferArgsType<S extends RpcArgsSchema | undefined>
= S extends readonly [] ? []
: S extends readonly [infer H, ...infer T]
? H extends GenericSchema
? T extends readonly GenericSchema[]
? [InferInput<H>, ...InferArgsType<T>]
? H extends StandardSchemaV1
? T extends readonly StandardSchemaV1[]
? [StandardSchemaV1.InferInput<H>, ...InferArgsType<T>]
: never
: never
: never

/** Infers TypeScript return type from Valibot return schema */
/** Infers TypeScript return type from a Standard Schema return schema */
export type InferReturnType<S extends RpcReturnSchema | undefined>
= S extends RpcReturnSchema
? InferInput<S>
? StandardSchemaV1.InferInput<S>
: void
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ catalogs:
deps:
'@json-render/core': ^0.19.0
'@modelcontextprotocol/sdk': ^1.30.0
'@standard-schema/spec': ^1.1.0
'@valibot/to-json-schema': ^1.7.1
birpc: ^4.0.0
cac: ^7.0.0
Expand Down
Loading