Skip to content

Commit 9f29cfd

Browse files
committed
refactor(mcp): convert via native Standard JSON Schema; drop standard-json + quansync
Replace @standard-community/standard-json with the schema's own Standard JSON Schema converter (~standard.jsonSchema from @standard-schema/spec, implemented by e.g. zod 4). This keeps JSON-schema generation vendor-neutral and precise for validators that ship a converter, while removing the @standard-community/standard-json + quansync dependencies and reverting the async conversion back to synchronous. Validators without a native converter degrade to a permissive object schema. The inspect plugin additionally keeps @valibot/to-json-schema as a fallback so it still converts valibot schemas precisely. Technique adapted from PR #155.
1 parent 4ee6bc6 commit 9f29cfd

11 files changed

Lines changed: 158 additions & 206 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co
3636
## Conventions
3737

3838
- RPC functions must use `defineRpcFunction`; always namespace IDs `devframes:plugin:<slug>:<fn-name>` (matching the plugin's `@devframes/plugin-<slug>` package name).
39-
- **Stay validator-neutral.** `devframe` and every `@devframes/*` package must not introduce a preferred schema validator dependency — no `valibot`, `zod`, `arktype`, etc. in their runtime `dependencies`. `args`/`returns`/flag schemas are typed against [Standard Schema](https://standardschema.dev/) (`@standard-schema/spec`, types-only); first-party code that needs to author a schema uses the built-in zero-dep `devframe/utils/simple-schema` builder (deliberately minimal — not a general validator). JSON-schema conversion goes through `@standard-community/standard-json`, whose per-vendor converters are optional peers, so no validator is forced. Docs, by contrast, should point *users* at a real validator for their own integrations — recommend **valibot** (lightest) or **zod** (worth reusing if they already pull it via the JSON-render or MCP integrations).
39+
- **Stay validator-neutral.** `devframe` and every `@devframes/*` package must not introduce a preferred schema validator dependency — no `valibot`, `zod`, `arktype`, etc. in their runtime `dependencies`. `args`/`returns`/flag schemas are typed against [Standard Schema](https://standardschema.dev/) (`@standard-schema/spec`, types-only); first-party code that needs to author a schema uses the built-in zero-dep `devframe/utils/simple-schema` builder (deliberately minimal — not a general validator). JSON-schema conversion uses each schema's own Standard JSON Schema converter (`~standard.jsonSchema`, implemented by e.g. zod 4) when present and degrades to a permissive object otherwise — no converter library and no vendor dependency is required. Docs, by contrast, should point *users* at a real validator for their own integrations — recommend **valibot** (lightest) or **zod** (worth reusing if they already pull it via the JSON-render or MCP integrations).
4040
- Shared state via `devframe/utils/shared-state`; keep values serializable.
4141
- Utility imports use the package-path form `devframe/utils/*`, never relative `../utils/*`.
4242
- Dependencies go through the pnpm catalogs in `pnpm-workspace.yaml` (`cli`, `inlined`, `testing`, `types`) — add to a catalog and reference as `catalog:<name>`, don't pin versions in `package.json`.

packages/devframe/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@
8383
}
8484
},
8585
"dependencies": {
86-
"@standard-community/standard-json": "catalog:deps",
8786
"@standard-schema/spec": "catalog:deps",
8887
"birpc": "catalog:deps",
8988
"crossws": "catalog:deps",
@@ -92,7 +91,6 @@
9291
"mrmime": "catalog:deps",
9392
"nostics": "catalog:deps",
9493
"pathe": "catalog:deps",
95-
"quansync": "catalog:deps",
9694
"ufo": "catalog:deps"
9795
},
9896
"devDependencies": {
Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,56 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
12
import * as v from 'valibot'
23
import { describe, expect, it } from 'vitest'
34
import { argsToJsonSchema, returnToJsonSchema } from '../to-json-schema'
45

56
const PERMISSIVE = { type: 'object', additionalProperties: true }
67

8+
/** A Standard Schema that also implements the Standard JSON Schema converter (like zod 4). */
9+
function withJsonSchema(json: Record<string, unknown>): StandardSchemaV1 {
10+
return {
11+
'~standard': {
12+
version: 1,
13+
vendor: 'test',
14+
validate: (value: unknown) => ({ value }),
15+
jsonSchema: {
16+
input: () => json,
17+
output: () => json,
18+
},
19+
} as StandardSchemaV1['~standard'],
20+
}
21+
}
22+
723
describe('argsToJsonSchema', () => {
8-
it('returns an empty object schema when no args', async () => {
9-
const { schema, unwrapped } = await argsToJsonSchema(undefined)
24+
it('returns an empty object schema when no args', () => {
25+
const { schema, unwrapped } = argsToJsonSchema(undefined)
1026
expect(unwrapped).toBe(false)
1127
expect(schema).toEqual({ type: 'object', properties: {} })
1228
})
1329

14-
it('advertises each positional arg under arg0/arg1/... with precise per-vendor conversion', async () => {
15-
const { schema, unwrapped } = await argsToJsonSchema([v.string(), v.number()])
16-
expect(unwrapped).toBe(false)
17-
expect(schema).toMatchObject({
18-
type: 'object',
19-
required: ['arg0', 'arg1'],
20-
additionalProperties: false,
21-
})
22-
const props = (schema as any).properties
23-
// valibot vendor → precise conversion via @standard-community/standard-json.
24-
expect(props.arg0).toMatchObject({ type: 'string' })
25-
expect(props.arg1).toMatchObject({ type: 'number' })
30+
it('uses the schema\'s own Standard JSON Schema converter when present', () => {
31+
const { schema } = argsToJsonSchema([withJsonSchema({ type: 'string' })])
32+
expect((schema as any).properties.arg0).toEqual({ type: 'string' })
2633
})
2734

28-
it('falls back to a permissive object for vendors without a converter', async () => {
29-
const foreign = {
30-
'~standard': { version: 1 as const, vendor: 'acme', validate: (value: unknown) => ({ value }) },
31-
}
32-
const { schema } = await argsToJsonSchema([foreign])
35+
it('falls back to a permissive object for validators without a native converter (valibot)', () => {
36+
const { schema } = argsToJsonSchema([v.string(), v.number()])
3337
expect((schema as any).properties.arg0).toEqual(PERMISSIVE)
38+
expect((schema as any).properties.arg1).toEqual(PERMISSIVE)
39+
expect(schema).toMatchObject({ type: 'object', required: ['arg0', 'arg1'], additionalProperties: false })
3440
})
3541
})
3642

3743
describe('returnToJsonSchema', () => {
38-
it('returns undefined when no schema is provided', async () => {
39-
expect(await returnToJsonSchema(undefined)).toBeUndefined()
44+
it('returns undefined when no schema is provided', () => {
45+
expect(returnToJsonSchema(undefined)).toBeUndefined()
46+
})
47+
48+
it('uses the native converter when present', () => {
49+
expect(returnToJsonSchema(withJsonSchema({ type: 'object', properties: { ok: { type: 'boolean' } } })))
50+
.toEqual({ type: 'object', properties: { ok: { type: 'boolean' } } })
4051
})
4152

42-
it('converts a declared return schema precisely for known vendors', async () => {
43-
const schema = await returnToJsonSchema(v.object({ ok: v.boolean() }))
44-
expect((schema as any).type).toBe('object')
45-
expect((schema as any).properties.ok).toMatchObject({ type: 'boolean' })
53+
it('falls back to permissive for validators without a native converter', () => {
54+
expect(returnToJsonSchema(v.object({ ok: v.boolean() }))).toEqual(PERMISSIVE)
4655
})
4756
})

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export async function createMcpServer(
148148

149149
function registerToolHandlers(server: Server, ctx: DevframeNodeContext): void {
150150
server.setRequestHandler('tools/list', async () => {
151-
const tools = await Promise.all(ctx.agent.list().tools.map(tool => projectTool(tool, ctx)))
151+
const tools = ctx.agent.list().tools.map(tool => projectTool(tool, ctx))
152152
return { tools }
153153
})
154154

@@ -157,7 +157,7 @@ function registerToolHandlers(server: Server, ctx: DevframeNodeContext): void {
157157
try {
158158
const tool = ctx.agent.getTool(name)
159159
const outputSchema = tool
160-
? tool.outputSchema ?? await computeOutputSchema(tool, ctx)
160+
? tool.outputSchema ?? computeOutputSchema(tool, ctx)
161161
: undefined
162162
const result = await ctx.agent.invoke(name, args ?? {})
163163
return {
@@ -248,9 +248,9 @@ function registerResourceHandlers(
248248
})
249249
}
250250

251-
async function projectTool(tool: AgentTool, ctx: DevframeNodeContext): Promise<Tool> {
252-
const inputSchema = tool.inputSchema ?? await computeInputSchema(tool, ctx)
253-
const outputSchema = tool.outputSchema ?? await computeOutputSchema(tool, ctx)
251+
function projectTool(tool: AgentTool, ctx: DevframeNodeContext): Tool {
252+
const inputSchema = tool.inputSchema ?? computeInputSchema(tool, ctx)
253+
const outputSchema = tool.outputSchema ?? computeOutputSchema(tool, ctx)
254254
return {
255255
name: tool.id,
256256
title: tool.title,
@@ -265,17 +265,17 @@ async function projectTool(tool: AgentTool, ctx: DevframeNodeContext): Promise<T
265265
} as Tool
266266
}
267267

268-
async function computeInputSchema(tool: AgentTool, ctx: DevframeNodeContext): Promise<unknown> {
268+
function computeInputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown {
269269
if (tool.kind !== 'rpc' || !tool.rpcName)
270270
return { type: 'object', properties: {} }
271271
const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext<DevframeNodeContext> | undefined
272272
if (!def)
273273
return { type: 'object', properties: {} }
274274
const args = def.args as readonly StandardSchemaV1[] | undefined
275-
return (await argsToJsonSchema(args)).schema
275+
return argsToJsonSchema(args).schema
276276
}
277277

278-
async function computeOutputSchema(tool: AgentTool, ctx: DevframeNodeContext): Promise<unknown> {
278+
function computeOutputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown {
279279
if (tool.kind !== 'rpc' || !tool.rpcName)
280280
return undefined
281281
const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext<DevframeNodeContext> | undefined

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

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,37 @@
1-
import type { StandardSchemaV1 } from '@standard-schema/spec'
2-
import { toJsonSchema } from '@standard-community/standard-json'
1+
import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'
32

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

5+
/** A `~standard` prop that may also carry the Standard JSON Schema converter. */
6+
type MaybeJsonSchema = StandardSchemaV1['~standard'] & Partial<StandardJSONSchemaV1['~standard']>
7+
68
/**
79
* Convert a Standard Schema to JSON Schema for the agent/MCP surface.
810
*
9-
* `@standard-community/standard-json` dispatches on the schema's
10-
* `~standard` vendor (valibot, zod, arktype, …) and lazily loads that
11-
* vendor's converter, so precise schemas require the matching converter
12-
* to be installed (e.g. `@valibot/to-json-schema`, `zod-to-json-schema`).
13-
* When no converter is available — or conversion fails — we degrade to a
14-
* permissive object schema so the surface never throws and no validator is
15-
* forced.
11+
* Devframe stays validator-neutral, so conversion uses the schema's own
12+
* [Standard JSON Schema](https://standardschema.dev/) converter
13+
* (`~standard.jsonSchema`) when the validator provides one — zod 4 does,
14+
* for example. Validators without a native converter (e.g. valibot) degrade
15+
* to a permissive object schema rather than pulling in a converter library.
1616
*/
17-
async function safeToJsonSchema(schema: StandardSchemaV1): Promise<unknown> {
18-
try {
19-
return await toJsonSchema(schema)
20-
}
21-
catch {
22-
return FALLBACK_OBJECT_SCHEMA
17+
function safeToJsonSchema(schema: StandardSchemaV1): unknown {
18+
const standard = schema['~standard'] as MaybeJsonSchema
19+
if (standard.jsonSchema) {
20+
try {
21+
return standard.jsonSchema.input({ target: 'draft-2020-12' })
22+
}
23+
catch {
24+
return FALLBACK_OBJECT_SCHEMA
25+
}
2326
}
27+
return FALLBACK_OBJECT_SCHEMA
2428
}
2529

2630
/**
2731
* JSON Schema for an RPC return value on the agent/MCP surface.
2832
* @internal
2933
*/
30-
export async function returnToJsonSchema(schema: StandardSchemaV1 | undefined): Promise<unknown> {
34+
export function returnToJsonSchema(schema: StandardSchemaV1 | undefined): unknown {
3135
if (!schema)
3236
return undefined
3337
return safeToJsonSchema(schema)
@@ -42,17 +46,17 @@ export async function returnToJsonSchema(schema: StandardSchemaV1 | undefined):
4246
* Returns `{ type: 'object', properties: {} }` when there are no args.
4347
* @internal
4448
*/
45-
export async function argsToJsonSchema(
49+
export function argsToJsonSchema(
4650
args: readonly StandardSchemaV1[] | undefined,
47-
): Promise<{ schema: unknown, unwrapped: boolean }> {
51+
): { schema: unknown, unwrapped: boolean } {
4852
if (!args || args.length === 0)
4953
return { schema: { type: 'object', properties: {} }, unwrapped: false }
5054

5155
const properties: Record<string, unknown> = {}
5256
const required: string[] = []
5357
for (let i = 0; i < args.length; i++) {
5458
const key = `arg${i}`
55-
properties[key] = await safeToJsonSchema(args[i]!)
59+
properties[key] = safeToJsonSchema(args[i]!)
5660
required.push(key)
5761
}
5862

plugins/inspect/package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,9 @@
5656
}
5757
},
5858
"dependencies": {
59-
"@standard-community/standard-json": "catalog:deps",
59+
"@valibot/to-json-schema": "catalog:deps",
6060
"cac": "catalog:deps",
61-
"nostics": "catalog:deps",
62-
"quansync": "catalog:deps"
61+
"nostics": "catalog:deps"
6362
},
6463
"devDependencies": {
6564
"@antfu/design": "catalog:frontend",
Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
1-
import type { StandardSchemaV1 } from '@standard-schema/spec'
2-
import { toJsonSchema } from '@standard-community/standard-json'
1+
import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'
2+
import { toJsonSchema } from '@valibot/to-json-schema'
33

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

6-
async function convert(schema: unknown): Promise<unknown> {
6+
/** A `~standard` prop that may also carry the Standard JSON Schema converter. */
7+
type MaybeJsonSchema = StandardSchemaV1['~standard'] & Partial<StandardJSONSchemaV1['~standard']>
8+
9+
/**
10+
* Convert a schema to JSON Schema for the inspector, vendor-neutrally.
11+
*
12+
* Prefers the schema's own Standard JSON Schema converter
13+
* (`~standard.jsonSchema`, implemented by e.g. zod 4), then falls back to
14+
* valibot's converter, then to a permissive object — so introspection never
15+
* throws regardless of which validator produced the schema.
16+
*/
17+
function convert(schema: unknown): unknown {
18+
const standard = (schema as StandardSchemaV1)['~standard'] as MaybeJsonSchema
719
try {
8-
return await toJsonSchema(schema as StandardSchemaV1)
20+
if (standard.jsonSchema)
21+
return standard.jsonSchema.input({ target: 'draft-2020-12' })
22+
return toJsonSchema(schema as never)
923
}
1024
catch {
1125
return FALLBACK_SCHEMA
@@ -14,14 +28,9 @@ async function convert(schema: unknown): Promise<unknown> {
1428

1529
/**
1630
* Convert an RPC return schema to JSON Schema, swallowing conversion
17-
* failures (unsupported vendor / missing converter) into a permissive
18-
* fallback so introspection never throws.
19-
*
20-
* Conversion is vendor-neutral via `@standard-community/standard-json`,
21-
* which loads the matching per-vendor converter on demand (valibot, zod,
22-
* arktype, …) — install the converter for the vendor you inspect.
31+
* failures into a permissive fallback so introspection never throws.
2332
*/
24-
export async function returnSchemaToJson(schema: unknown): Promise<unknown> {
33+
export function returnSchemaToJson(schema: unknown): unknown {
2534
if (!schema)
2635
return undefined
2736
return convert(schema)
@@ -32,11 +41,11 @@ export async function returnSchemaToJson(schema: unknown): Promise<unknown> {
3241
* (`type: 'array'` + `prefixItems`). Returns `undefined` when the function
3342
* declares no args.
3443
*/
35-
export async function argsSchemaToJson(args: readonly unknown[] | undefined): Promise<unknown> {
44+
export function argsSchemaToJson(args: readonly unknown[] | undefined): unknown {
3645
if (!args || args.length === 0)
3746
return undefined
3847
return {
3948
type: 'array',
40-
prefixItems: await Promise.all(args.map(arg => convert(arg))),
49+
prefixItems: args.map(arg => convert(arg)),
4150
}
4251
}

plugins/inspect/src/rpc/functions/list-functions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ export const listFunctions = defineInspectRpc({
4646
hasHandler: !!fn.handler,
4747
invokable: INVOKABLE_TYPES.has(type),
4848
agent,
49-
argsSchema: await argsSchemaToJson(fn.args as readonly unknown[] | undefined),
50-
returnsSchema: await returnSchemaToJson(fn.returns),
49+
argsSchema: argsSchemaToJson(fn.args as readonly unknown[] | undefined),
50+
returnsSchema: returnSchemaToJson(fn.returns),
5151
})
5252
}
5353
out.sort((a, b) => a.name.localeCompare(b.name))

0 commit comments

Comments
 (0)