diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 0333afa4..41c74bab 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -82,6 +82,7 @@ } }, "dependencies": { + "@standard-schema/spec": "catalog:deps", "@valibot/to-json-schema": "catalog:deps", "birpc": "catalog:deps", "crossws": "catalog:deps", diff --git a/packages/devframe/src/adapters/mcp/__tests__/to-json-schema.test.ts b/packages/devframe/src/adapters/mcp/__tests__/to-json-schema.test.ts index b275ec57..969fb463 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/to-json-schema.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/to-json-schema.test.ts @@ -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', @@ -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) @@ -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 }) + }) }) diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 5bb0ba4f..0e3840bb 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -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' @@ -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 { /** @@ -276,8 +276,8 @@ function computeInputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext | 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 { @@ -286,7 +286,7 @@ function computeOutputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext | 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' } { diff --git a/packages/devframe/src/adapters/mcp/to-json-schema.ts b/packages/devframe/src/adapters/mcp/to-json-schema.ts index ccf1b144..b9cdb283 100644 --- a/packages/devframe/src/adapters/mcp/to-json-schema.ts +++ b/packages/devframe/src/adapters/mcp/to-json-schema.ts @@ -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) } /** @@ -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 } @@ -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) } @@ -63,8 +58,13 @@ export function valibotArgsToJsonSchema( } } -function safeToJsonSchema(schema: GenericSchema): unknown { +type StandardSchemaProps = StandardSchemaV1['~standard'] & Partial + +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 { diff --git a/packages/devframe/src/rpc/types.test.ts b/packages/devframe/src/rpc/types.test.ts index f4a25a80..592654ba 100644 --- a/packages/devframe/src/rpc/types.test.ts +++ b/packages/devframe/src/rpc/types.test.ts @@ -1,4 +1,5 @@ /* eslint-disable unused-imports/no-unused-vars */ +import type { StandardSchemaV1 } from '@standard-schema/spec' import type { RpcDefinitionsToFunctions, RpcFunctionDefinitionToFunction, @@ -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(): StandardSchemaV1 { + 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({ @@ -74,6 +87,20 @@ describe('rpcFunctionDefinitionToFunction', () => { type Result = RpcFunctionDefinitionToFunction type _Test = AssertEqual string[]> }) + + it('should infer types from a non-valibot Standard Schema', () => { + const fn = defineRpcFunction({ + name: 'standardSchema', + args: [fakeSchema(), fakeSchema()], + returns: fakeSchema(), + handler: (a, b) => { + return a.length > b + }, + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual boolean> + }) }) describe('rpcDefinitionsToFunctions', () => { diff --git a/packages/devframe/src/rpc/types.ts b/packages/devframe/src/rpc/types.ts index 370c08e6..7fb84e82 100644 --- a/packages/devframe/src/rpc/types.ts +++ b/packages/devframe/src/rpc/types.ts @@ -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' @@ -93,10 +93,10 @@ export interface RpcFunctionSetupResult< dump?: RpcDumpDefinition } -/** 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. @@ -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 @@ -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 diff --git a/packages/devframe/src/rpc/utils.ts b/packages/devframe/src/rpc/utils.ts index 18e3fb46..a5785beb 100644 --- a/packages/devframe/src/rpc/utils.ts +++ b/packages/devframe/src/rpc/utils.ts @@ -1,4 +1,4 @@ -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 */ @@ -6,19 +6,19 @@ export type AssertEqual = (() => T extends X ? 1 : 2) extends (() => 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 readonly [] ? [] : S extends readonly [infer H, ...infer T] - ? H extends GenericSchema - ? T extends readonly GenericSchema[] - ? [InferInput, ...InferArgsType] + ? H extends StandardSchemaV1 + ? T extends readonly StandardSchemaV1[] + ? [StandardSchemaV1.InferInput, ...InferArgsType] : 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 - ? InferInput + ? StandardSchemaV1.InferInput : void diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc3a6773..bde9988e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ catalogs: '@modelcontextprotocol/sdk': specifier: ^1.30.0 version: 1.30.0 + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 '@valibot/to-json-schema': specifier: ^1.7.1 version: 1.7.1 @@ -782,6 +785,9 @@ importers: packages/devframe: dependencies: + '@standard-schema/spec': + specifier: catalog:deps + version: 1.1.0 '@valibot/to-json-schema': specifier: catalog:deps version: 1.7.1(valibot@1.4.2(typescript@6.0.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c5d35479..24c023c7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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