Skip to content

Commit 1bd966f

Browse files
posvaCopilot
andauthored
fix: infer JSON serialization for agent RPCs (#379)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent d5ddc01 commit 1bd966f

19 files changed

Lines changed: 585 additions & 2576 deletions

File tree

docs/content/1.guide/3.rpc.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,6 @@ Add an `agent` field to expose the function to coding agents over MCP:
193193
defineRpcFunction({
194194
name: 'get-modules',
195195
type: 'query',
196-
jsonSerializable: true,
197196
args: [v.object({ limit: v.number() })],
198197
returns: v.array(v.object({ id: v.string(), size: v.number() })),
199198
agent: {
@@ -207,7 +206,7 @@ defineRpcFunction({
207206
})
208207
```
209208

210-
Exposing a function over MCP requires `jsonSerializable: true`.
209+
The `agent` field implicitly enables strict JSON serialization because MCP consumes JSON-shaped data. Set `jsonSerializable: true` directly when an RPC-only function also benefits from that contract.
211210

212211
## What's next
213212

docs/content/6.errors/DF0019.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
11
---
22
title: 'DF0019: Agent Requires JSON-Serializable RPC'
3-
description: 'RPC function "{name}" has agent set but jsonSerializable is not true; MCP requires JSON-serializable data.'
3+
description: 'RPC function "{name}" has agent set but jsonSerializable is false; MCP requires JSON-serializable data.'
44
---
55

66
## Message
77

8-
> RPC function "`{name}`" has `agent` set but `jsonSerializable` is not `true`; MCP requires JSON-serializable data.
8+
> RPC function "`{name}`" has `agent` set but `jsonSerializable` is `false`; MCP requires JSON-serializable data.
99
1010
## Cause
1111

12-
The `agent` field exposes an RPC function as an MCP tool, and MCP only consumes JSON-shaped data. A function with `agent` set is rejected unless it also declares `jsonSerializable: true`.
12+
The `agent` field exposes an RPC function as an MCP tool and implicitly enables strict JSON serialization. An explicit `jsonSerializable: false` conflicts with MCP's JSON-shaped data.
1313

1414
## Example
1515

1616
```ts
1717
defineRpcFunction({
1818
name: 'my-plugin:summary',
1919
agent: { description: 'Returns a summary' },
20-
handler: () => ({ items: [1, 2, 3] }), // ✗ throws DF0019: missing jsonSerializable: true
20+
jsonSerializable: false, // ✗ throws DF0019
21+
handler: () => ({ items: [1, 2, 3] }),
2122
})
2223
```
2324

2425
## Fix
2526

26-
Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it RPC-only.
27+
Remove `jsonSerializable: false` to use the implicit JSON contract, or remove `agent` to keep the function RPC-only.
2728

2829
```ts
2930
defineRpcFunction({
@@ -36,4 +37,4 @@ defineRpcFunction({
3637

3738
## Source
3839

39-
- [`packages/devframe/src/rpc/collector.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/rpc/collector.ts): `RpcFunctionsCollectorBase.register()` throws `DF0019` when a definition has `agent` set but is not declared `jsonSerializable: true`.
40+
- [`packages/devframe/src/rpc/agent-json-serialization.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/rpc/agent-json-serialization.ts): `ensureAgentJsonSerializable()` throws `DF0019` when a definition combines `agent` with `jsonSerializable: false` during registration or static dump collection.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { RpcFunctionDefinitionAny } from './types'
2+
import { diagnostics } from './diagnostics'
3+
4+
/**
5+
* Prevents using a coding-agent-exposed RPC function that is explicitly
6+
* marked as non-serializable, and marks these functions as serializable by
7+
* default.
8+
*
9+
* @internal
10+
*/
11+
export function ensureAgentJsonSerializable(fnDef: RpcFunctionDefinitionAny): void {
12+
if (fnDef.agent && fnDef.jsonSerializable === false)
13+
throw diagnostics.DF0019({ name: fnDef.name })
14+
if (fnDef.agent && !fnDef.jsonSerializable)
15+
fnDef.jsonSerializable = true
16+
}

packages/devframe/src/rpc/collector.test.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { describe, expect, it, vi } from 'vitest'
22
import { RpcFunctionsCollectorBase } from './collector'
33

44
describe('agent gating (DF0019)', () => {
5-
it('rejects registration when agent is set without jsonSerializable: true', () => {
5+
it('infers jsonSerializable: true when agent is set', () => {
66
const collector = new RpcFunctionsCollectorBase({})
7-
expect(() => collector.register({
7+
collector.register({
88
name: 'plugin:fn',
99
agent: { description: 'x' },
1010
handler: () => 0,
11-
} as any)).toThrowError(/MCP requires JSON-serializable/)
11+
} as any)
12+
expect(collector.get('plugin:fn')?.jsonSerializable).toBe(true)
1213
})
1314

1415
it('rejects when agent + jsonSerializable: false', () => {
@@ -40,14 +41,15 @@ describe('agent gating (DF0019)', () => {
4041
} as any)).not.toThrow()
4142
})
4243

43-
it('also enforces the gate on update()', () => {
44+
it('also infers jsonSerializable: true on update()', () => {
4445
const collector = new RpcFunctionsCollectorBase({})
4546
collector.register({ name: 'plugin:fn', handler: () => 0 } as any)
46-
expect(() => collector.update({
47+
collector.update({
4748
name: 'plugin:fn',
4849
agent: { description: 'x' },
4950
handler: () => 0,
50-
} as any)).toThrowError(/MCP requires JSON-serializable/)
51+
} as any)
52+
expect(collector.get('plugin:fn')?.jsonSerializable).toBe(true)
5153
})
5254
})
5355

packages/devframe/src/rpc/collector.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { RpcArgsSchema, RpcFunctionDefinition, RpcFunctionsCollector, RpcReturnSchema } from './types'
2+
import { ensureAgentJsonSerializable } from './agent-json-serialization'
23
import { diagnostics } from './diagnostics'
34
import { getRpcHandler } from './handler'
45

@@ -39,20 +40,20 @@ export class RpcFunctionsCollectorBase<
3940
}) as LocalFunctions
4041
}
4142

42-
register(fn: RpcFunctionDefinition<string, any, any, any, any, any, SetupContext>, force = false): void {
43-
if (this.definitions.has(fn.name) && !force) {
44-
throw diagnostics.DF0021({ name: fn.name })
43+
register(fnDef: RpcFunctionDefinition<string, any, any, any, any, any, SetupContext>, force = false): void {
44+
if (this.definitions.has(fnDef.name) && !force) {
45+
throw diagnostics.DF0021({ name: fnDef.name })
4546
}
46-
assertAgentJsonSerializable(fn)
47-
this.definitions.set(fn.name, fn)
48-
this._onChanged.forEach(cb => cb(fn.name))
47+
ensureAgentJsonSerializable(fnDef)
48+
this.definitions.set(fnDef.name, fnDef)
49+
this._onChanged.forEach(cb => cb(fnDef.name))
4950
}
5051

5152
update(fn: RpcFunctionDefinition<string, any, any, any, any, any, SetupContext>, force = false): void {
5253
if (!this.definitions.has(fn.name) && !force) {
5354
throw diagnostics.DF0022({ name: fn.name })
5455
}
55-
assertAgentJsonSerializable(fn)
56+
ensureAgentJsonSerializable(fn)
5657
this.definitions.set(fn.name, fn)
5758
this._onChanged.forEach(cb => cb(fn.name))
5859
}
@@ -93,10 +94,3 @@ export class RpcFunctionsCollectorBase<
9394
return Array.from(this.definitions.keys())
9495
}
9596
}
96-
97-
function assertAgentJsonSerializable(
98-
fn: RpcFunctionDefinition<string, any, any, any, any, any, any>,
99-
): void {
100-
if (fn.agent && fn.jsonSerializable !== true)
101-
throw diagnostics.DF0019({ name: fn.name })
102-
}

packages/devframe/src/rpc/diagnostics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ export const diagnostics = defineDiagnostics({
55
codes: {
66
DF0019: {
77
why: (p: { name: string }) =>
8-
`RPC function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\`; MCP requires JSON-serializable data.`,
9-
fix: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it RPC-only.',
8+
`RPC function "${p.name}" has \`agent\` set but \`jsonSerializable\` is \`false\`; MCP requires JSON-serializable data.`,
9+
fix: 'Remove `jsonSerializable: false`, or remove `agent` to keep it RPC-only.',
1010
},
1111
DF0020: {
1212
why: (p: { name: string, type: string, path: string }) =>

packages/devframe/src/rpc/dump/__tests__/static.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,26 @@ describe('collectStaticRpcDump', () => {
2525
expect(result.files[expectedPath]?.serialization).toBe('json')
2626
})
2727

28+
it('infers JSON serialization for directly collected coding-agent-exposed functions', async () => {
29+
const getVersion = defineRpcFunction({
30+
name: 'test:agent-version',
31+
type: 'static',
32+
agent: { description: 'Return the current version.' },
33+
handler: () => '1.0.0',
34+
})
35+
36+
const result = await collectStaticRpcDump([getVersion], {})
37+
const expectedPath = `${DEVFRAME_RPC_DUMP_DIRNAME}/test~agent-version.static.json`
38+
39+
expect(getVersion.jsonSerializable).toBe(true)
40+
expect(result.manifest['test:agent-version']).toEqual({
41+
type: 'static',
42+
path: expectedPath,
43+
serialization: 'json',
44+
})
45+
expect(result.files[expectedPath]?.serialization).toBe('json')
46+
})
47+
2848
it('collects static rpc output into sharded file entries', async () => {
2949
const getVersion = defineRpcFunction({
3050
name: 'test:get-version',

packages/devframe/src/rpc/dump/static.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { RpcDumpRecord, RpcFunctionDefinitionAny } from '../types'
22
import {
33
DEVFRAME_RPC_DUMP_DIRNAME,
44
} from 'devframe/constants'
5+
import { ensureAgentJsonSerializable } from '../agent-json-serialization'
56
import { getRpcHandler } from '../handler'
67
import { dumpFunctions } from './collect'
78

@@ -131,6 +132,7 @@ export async function collectStaticRpcDump(
131132
const files: Record<string, StaticRpcDumpFile> = {}
132133

133134
for (const definition of definitions) {
135+
ensureAgentJsonSerializable(definition)
134136
const type = definition.type ?? 'query'
135137
const serialization: StaticRpcDumpSerialization
136138
= definition.jsonSerializable === true ? 'json' : 'structured-clone'

packages/devframe/src/rpc/types.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ function schema<Input, Output = Input>(): StandardSchemaV1<Input, Output> {
2525
}
2626

2727
describe('rpcFunctionDefinitionToFunction', () => {
28+
it('requires args and returns schemas together', () => {
29+
// @ts-expect-error args and returns schemas must be provided together
30+
defineRpcFunction({
31+
name: 'missingReturns',
32+
args: [v.string()],
33+
})
34+
35+
// @ts-expect-error args and returns schemas must be provided together
36+
defineRpcFunction({
37+
name: 'missingArgs',
38+
returns: v.string(),
39+
})
40+
})
41+
2842
it('should infer types from generic parameters when no schemas', () => {
2943
const fn = defineRpcFunction({
3044
name: 'noSchema',

0 commit comments

Comments
 (0)