Skip to content

Commit c25594f

Browse files
committed
refactor(agent): shrink the public API surface
- drop the devframe/utils/valibot-json-schema subpath: AgentToolInput gains valibot args (the same shape RPC definitions carry); the agent host derives the JSON-Schema input internally, and the hub passes schemas through untouched — conversion is an implementation detail again - devframe/node exports only registerDevframeInstance (+ its two types) from the instance registry; the read/probe/prune helpers stay internal to the connector
1 parent 3a7adf9 commit c25594f

13 files changed

Lines changed: 92 additions & 52 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,8 @@ function projectTool(tool: AgentTool, ctx: DevframeNodeContext): Tool {
348348
}
349349

350350
function computeInputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown {
351+
if (tool.kind === 'tool')
352+
return argsToJsonSchema(tool.args).schema
351353
if (tool.kind !== 'rpc' || !tool.rpcName)
352354
return { type: 'object', properties: {} }
353355
const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext<DevframeNodeContext> | undefined

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,39 @@ describe('devToolsAgentHost', () => {
270270
})
271271
})
272272

273+
describe('valibot args on tool inputs', () => {
274+
it('derives the JSON-Schema input from a single object schema (unwrapped)', async () => {
275+
const v = await import('valibot')
276+
const ctx = createContext()
277+
ctx.agent.registerTool({
278+
id: 'schema:tool',
279+
description: 'Schema-typed.',
280+
args: [v.object({ name: v.optional(v.string()) })],
281+
handler: args => args,
282+
})
283+
284+
const tool = ctx.agent.getTool('schema:tool')!
285+
const schema = tool.inputSchema as { type: string, properties: Record<string, unknown> }
286+
expect(schema.type).toBe('object')
287+
expect(Object.keys(schema.properties)).toEqual(['name'])
288+
})
289+
290+
it('an explicit inputSchema override wins over args', async () => {
291+
const v = await import('valibot')
292+
const ctx = createContext()
293+
ctx.agent.registerTool({
294+
id: 'override:tool',
295+
description: 'Override.',
296+
args: [v.object({ ignored: v.string() })],
297+
inputSchema: { type: 'object', properties: { custom: { type: 'string' } } },
298+
handler: () => {},
299+
})
300+
301+
const schema = ctx.agent.getTool('override:tool')!.inputSchema as { properties: Record<string, unknown> }
302+
expect(Object.keys(schema.properties)).toEqual(['custom'])
303+
})
304+
})
305+
273306
describe('registerToolProvider()', () => {
274307
it('queries the provider lazily on list/getTool/invoke', async () => {
275308
const ctx = createContext()

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ export class DevframeAgentHost implements DevframeAgentHostType {
211211
description: input.description,
212212
safety: input.safety ?? 'action',
213213
tags: input.tags,
214+
// Standard Schema `args` are carried raw (mirroring how an RPC-backed
215+
// tool defers to `ctx.rpc.definitions`) — consumers (the MCP adapter)
216+
// convert to JSON Schema on demand. An explicit `inputSchema` override
217+
// wins when given.
218+
args: input.args,
214219
inputSchema: input.inputSchema,
215220
outputSchema: input.outputSchema,
216221
examples: input.examples,

packages/devframe/src/node/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ export type { RpcFunctionsHost } from './host-functions'
99
export * from './host-h3'
1010
export * from './host-services'
1111
export * from './host-views'
12-
export * from './instance-registry'
12+
// Only registration is public — custom hosts (e.g. @devframes/next) record
13+
// themselves; the read/probe/prune helpers stay internal to the connector.
14+
export { registerDevframeInstance } from './instance-registry'
15+
export type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry'
1316
export * from './rpc-shared-state'
1417
export * from './rpc-streaming'
1518
export * from './scope'

packages/devframe/src/types/agent.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
12
import type { RpcFunctionAgentOptions } from '../rpc/types'
23
import type { EventEmitter } from './events'
34

@@ -24,6 +25,14 @@ export interface AgentTool {
2425
tags?: readonly string[]
2526
/** Present for `kind === 'rpc'` — points to the RPC function name. */
2627
rpcName?: string
28+
/**
29+
* Positional Standard Schemas describing a `kind: 'tool'` entry's
30+
* arguments — carried on the tool itself (mirroring how `rpcName` defers
31+
* an RPC-backed tool's schemas to `ctx.rpc.definitions`) so consumers
32+
* (e.g. the MCP adapter) convert Standard Schema → JSON Schema on demand,
33+
* same as RPC `args`.
34+
*/
35+
args?: readonly StandardSchemaV1[]
2736
/** JSON Schema describing the input (positional args synthesized to an object). */
2837
inputSchema?: unknown
2938
/** JSON Schema describing the output. */
@@ -44,6 +53,17 @@ export interface AgentToolInput {
4453
description: string
4554
safety?: 'read' | 'action' | 'destructive'
4655
tags?: readonly string[]
56+
/**
57+
* Positional Standard Schemas describing the tool's arguments — the same
58+
* shape RPC definitions carry (any [Standard Schema](https://standardschema.dev/)
59+
* validator: valibot, zod, arktype, devframe's built-in `s` builder, …).
60+
* Each is advertised under `arg0` / `arg1` / … on the tool's JSON-Schema
61+
* input, matching how the agent bridge coerces the incoming payload back
62+
* into positional arguments. Purely descriptive: the handler still
63+
* receives the caller's args object as-is.
64+
*/
65+
args?: readonly StandardSchemaV1[]
66+
/** Raw JSON-Schema input override. Prefer {@link args}. */
4767
inputSchema?: unknown
4868
outputSchema?: unknown
4969
examples?: readonly { args: unknown[], description?: string }[]

packages/hub/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,20 @@
4242
"devframe": "workspace:*"
4343
},
4444
"dependencies": {
45+
"@standard-schema/spec": "catalog:deps",
4546
"birpc": "catalog:deps",
4647
"destr": "catalog:deps",
4748
"nostics": "catalog:deps",
4849
"pathe": "catalog:deps",
4950
"perfect-debounce": "catalog:deps",
5051
"tinyexec": "catalog:deps",
51-
"valibot": "catalog:deps",
5252
"zigpty": "catalog:deps"
5353
},
5454
"devDependencies": {
5555
"@types/node": "catalog:types",
5656
"devframe": "workspace:*",
5757
"mlly": "catalog:build",
58-
"tsdown": "catalog:build"
58+
"tsdown": "catalog:build",
59+
"valibot": "catalog:deps"
5960
}
6061
}

packages/hub/src/node/__tests__/host-commands.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,10 @@ describe('devframeCommandsHost agent bridge', () => {
110110
expect((tool.inputSchema as { type: string }).type).toBe('object')
111111
expect(agent.list().tools.map(t => t.id)).toEqual(['demo:greet'])
112112

113-
// A single object args schema is unwrapped — the MCP args object lands
114-
// as the handler's first positional argument.
115-
await expect(agent.invoke('demo:greet', { name: 'devframe' })).resolves.toBe('done')
113+
// Each declared arg schema is advertised (and read back) under its own
114+
// `argN` key — the MCP args object's `arg0` becomes the handler's first
115+
// positional argument.
116+
await expect(agent.invoke('demo:greet', { arg0: { name: 'devframe' } })).resolves.toBe('done')
116117
expect(calls).toEqual([[{ name: 'devframe' }]])
117118
})
118119

packages/hub/src/node/host-commands.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { AgentToolInput, AgentToolProviderHandle } from 'devframe/types'
22
import type {
3+
DevframeCommandAgentOptions,
34
DevframeCommandHandle,
45
DevframeCommandsHost as DevframeCommandsHostType,
56
DevframeServerCommandEntry,
67
DevframeServerCommandInput,
78
} from '../types/commands'
89
import type { DevframeHubContext } from './context'
910
import { createEventEmitter } from 'devframe/utils/events'
10-
import { valibotArgsToJsonSchema } from 'devframe/utils/valibot-json-schema'
1111
import { diagnostics } from './diagnostics'
1212

1313
function findChildCommand(command: DevframeServerCommandInput, id: string): DevframeServerCommandInput | undefined {
@@ -145,7 +145,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType {
145145
}
146146

147147
private toSerializable(cmd: DevframeServerCommandInput): DevframeServerCommandEntry {
148-
// `agent` stays server-side: it carries valibot schemas (not wire-safe)
148+
// `agent` stays server-side: it carries Standard Schema validators (not wire-safe)
149149
// and only concerns the agent projection, not the palette.
150150
const { handler: _, agent: __, children, ...rest } = cmd
151151
return {
@@ -179,16 +179,16 @@ export class DevframeCommandsHost implements DevframeCommandsHostType {
179179
const walk = (command: DevframeServerCommandInput): void => {
180180
const agent = command.agent
181181
if (agent && command.handler) {
182-
const { schema, unwrapped } = valibotArgsToJsonSchema(agent.args)
183182
tools.push({
184183
id: command.id,
185184
title: agent.title ?? command.title,
186185
description: agent.description,
187186
safety: agent.safety ?? 'action',
188187
tags: agent.tags,
189-
inputSchema: schema,
188+
// The agent host derives the tool's JSON-Schema input from these.
189+
args: agent.args,
190190
handler: async (args: unknown) =>
191-
this.execute(command.id, ...coercePositionalArgs(args, agent.args, unwrapped)),
191+
this.execute(command.id, ...coercePositionalArgs(args, agent.args)),
192192
})
193193
}
194194
for (const child of command.children ?? [])
@@ -201,20 +201,17 @@ export class DevframeCommandsHost implements DevframeCommandsHostType {
201201
}
202202

203203
/**
204-
* Map the single-object args an MCP client sends onto the command handler's
205-
* positional parameters, mirroring the agent host's RPC coercion: no declared
206-
* schemas → zero-arg call; a single unwrapped object schema → the object
207-
* itself; positional schemas → `arg0..argN` keys in order.
204+
* Map the `arg0`/`arg1`/… keyed object an MCP client sends onto the command
205+
* handler's positional parametersmirroring the agent host's RPC
206+
* coercion: no declared schemas → zero-arg call; each declared schema reads
207+
* its own `argN` key, in order.
208208
*/
209209
function coercePositionalArgs(
210210
args: unknown,
211-
schemas: readonly unknown[] | undefined,
212-
unwrapped: boolean,
211+
schemas: DevframeCommandAgentOptions['args'],
213212
): unknown[] {
214213
if (!schemas || schemas.length === 0)
215214
return []
216-
if (unwrapped)
217-
return [args ?? {}]
218215
const obj = (args ?? {}) as Record<string, unknown>
219216
return schemas.map((_, i) => obj[`arg${i}`])
220217
}

packages/hub/src/types/commands.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
12
import type { EventEmitter } from 'devframe/types'
2-
import type { GenericSchema } from 'valibot'
33
import type { DevframeDockEntryIcon } from './docks'
44

55
export interface DevframeCommandKeybinding {
@@ -73,12 +73,13 @@ export interface DevframeCommandAgentOptions {
7373
/** Free-form tags for grouping/filtering. */
7474
tags?: readonly string[]
7575
/**
76-
* Positional valibot schemas for the handler's arguments, converted to the
77-
* tool's JSON-Schema input (a single `v.object(...)` schema is unwrapped —
78-
* the friendliest shape at the agent boundary). Omitted: the tool takes no
79-
* arguments.
76+
* Positional [Standard Schema](https://standardschema.dev/) validators for
77+
* the handler's arguments — the same shape RPC definitions carry (valibot,
78+
* zod, arktype, devframe's built-in `s` builder, …). Each is advertised
79+
* under `arg0` / `arg1` / … on the tool's JSON-Schema input. Omitted: the
80+
* tool takes no arguments.
8081
*/
81-
args?: readonly GenericSchema[]
82+
args?: readonly StandardSchemaV1[]
8283
}
8384

8485
/**

plans/031-agent-native-mcp-wave.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ literal "/_next/mcp" shape on devframe primitives.
6969
resource projection (which stays — many clients only consume tools).
7070
7. **Hub commands → agent bridge** — opt-in `agent?: { description, safety?,
7171
args? }` on `DevframeServerCommandInput` (mirrors the RPC convention;
72-
description required; optional valibot args schema reusing
73-
`valibotArgsToJsonSchema`, zero-arg default). `createHubContext` projects
72+
description required; optional valibot args schemas carried through
73+
`AgentToolInput.args` — JSON-Schema conversion stays an internal detail of
74+
the agent host/MCP adapter; zero-arg default). `createHubContext` projects
7475
agent-flagged, handler-bearing server commands into `ctx.agent` tools,
7576
tracking register/update/unregister. `when` clauses evaluate client-side
7677
only and are **not** enforced for agent calls — documented caveat.

0 commit comments

Comments
 (0)