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
3 changes: 1 addition & 2 deletions docs/content/1.guide/3.rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,6 @@ Add an `agent` field to expose the function to coding agents over MCP:
defineRpcFunction({
name: 'get-modules',
type: 'query',
jsonSerializable: true,
args: [v.object({ limit: v.number() })],
returns: v.array(v.object({ id: v.string(), size: v.number() })),
agent: {
Expand All @@ -207,7 +206,7 @@ defineRpcFunction({
})
```

Exposing a function over MCP requires `jsonSerializable: true`.
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.

## What's next

Expand Down
13 changes: 7 additions & 6 deletions docs/content/6.errors/DF0019.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
---
title: 'DF0019: Agent Requires JSON-Serializable RPC'
description: 'RPC function "{name}" has agent set but jsonSerializable is not true; MCP requires JSON-serializable data.'
description: 'RPC function "{name}" has agent set but jsonSerializable is false; MCP requires JSON-serializable data.'
---

## Message

> RPC function "`{name}`" has `agent` set but `jsonSerializable` is not `true`; MCP requires JSON-serializable data.
> RPC function "`{name}`" has `agent` set but `jsonSerializable` is `false`; MCP requires JSON-serializable data.

## Cause

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`.
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.

## Example

```ts
defineRpcFunction({
name: 'my-plugin:summary',
agent: { description: 'Returns a summary' },
handler: () => ({ items: [1, 2, 3] }), // ✗ throws DF0019: missing jsonSerializable: true
jsonSerializable: false, // ✗ throws DF0019
handler: () => ({ items: [1, 2, 3] }),
})
```

## Fix

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

```ts
defineRpcFunction({
Expand All @@ -36,4 +37,4 @@ defineRpcFunction({

## Source

- [`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`.
- [`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.
16 changes: 16 additions & 0 deletions packages/devframe/src/rpc/agent-json-serialization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { RpcFunctionDefinitionAny } from './types'
import { diagnostics } from './diagnostics'

/**
* Prevents using a coding-agent-exposed RPC function that is explicitly
* marked as non-serializable, and marks these functions as serializable by
* default.
*
* @internal
*/
export function ensureAgentJsonSerializable(fnDef: RpcFunctionDefinitionAny): void {
if (fnDef.agent && fnDef.jsonSerializable === false)
throw diagnostics.DF0019({ name: fnDef.name })
if (fnDef.agent && !fnDef.jsonSerializable)
fnDef.jsonSerializable = true
}
Comment on lines +11 to +16
14 changes: 8 additions & 6 deletions packages/devframe/src/rpc/collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { describe, expect, it, vi } from 'vitest'
import { RpcFunctionsCollectorBase } from './collector'

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

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

it('also enforces the gate on update()', () => {
it('also infers jsonSerializable: true on update()', () => {
const collector = new RpcFunctionsCollectorBase({})
collector.register({ name: 'plugin:fn', handler: () => 0 } as any)
expect(() => collector.update({
collector.update({
name: 'plugin:fn',
Comment on lines +44 to 48
agent: { description: 'x' },
handler: () => 0,
} as any)).toThrowError(/MCP requires JSON-serializable/)
} as any)
expect(collector.get('plugin:fn')?.jsonSerializable).toBe(true)
})
})

Expand Down
22 changes: 8 additions & 14 deletions packages/devframe/src/rpc/collector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RpcArgsSchema, RpcFunctionDefinition, RpcFunctionsCollector, RpcReturnSchema } from './types'
import { ensureAgentJsonSerializable } from './agent-json-serialization'
import { diagnostics } from './diagnostics'
import { getRpcHandler } from './handler'

Expand Down Expand Up @@ -39,20 +40,20 @@ export class RpcFunctionsCollectorBase<
}) as LocalFunctions
}

register(fn: RpcFunctionDefinition<string, any, any, any, any, any, SetupContext>, force = false): void {
if (this.definitions.has(fn.name) && !force) {
throw diagnostics.DF0021({ name: fn.name })
register(fnDef: RpcFunctionDefinition<string, any, any, any, any, any, SetupContext>, force = false): void {
if (this.definitions.has(fnDef.name) && !force) {
throw diagnostics.DF0021({ name: fnDef.name })
}
assertAgentJsonSerializable(fn)
this.definitions.set(fn.name, fn)
this._onChanged.forEach(cb => cb(fn.name))
ensureAgentJsonSerializable(fnDef)
this.definitions.set(fnDef.name, fnDef)
this._onChanged.forEach(cb => cb(fnDef.name))
}

update(fn: RpcFunctionDefinition<string, any, any, any, any, any, SetupContext>, force = false): void {
if (!this.definitions.has(fn.name) && !force) {
throw diagnostics.DF0022({ name: fn.name })
}
assertAgentJsonSerializable(fn)
ensureAgentJsonSerializable(fn)
this.definitions.set(fn.name, fn)
this._onChanged.forEach(cb => cb(fn.name))
}
Expand Down Expand Up @@ -93,10 +94,3 @@ export class RpcFunctionsCollectorBase<
return Array.from(this.definitions.keys())
}
}

function assertAgentJsonSerializable(
fn: RpcFunctionDefinition<string, any, any, any, any, any, any>,
): void {
if (fn.agent && fn.jsonSerializable !== true)
throw diagnostics.DF0019({ name: fn.name })
}
4 changes: 2 additions & 2 deletions packages/devframe/src/rpc/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ export const diagnostics = defineDiagnostics({
codes: {
DF0019: {
why: (p: { name: string }) =>
`RPC function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\`; MCP requires JSON-serializable data.`,
fix: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it RPC-only.',
`RPC function "${p.name}" has \`agent\` set but \`jsonSerializable\` is \`false\`; MCP requires JSON-serializable data.`,
fix: 'Remove `jsonSerializable: false`, or remove `agent` to keep it RPC-only.',
},
DF0020: {
why: (p: { name: string, type: string, path: string }) =>
Expand Down
20 changes: 20 additions & 0 deletions packages/devframe/src/rpc/dump/__tests__/static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ describe('collectStaticRpcDump', () => {
expect(result.files[expectedPath]?.serialization).toBe('json')
})

it('infers JSON serialization for directly collected coding-agent-exposed functions', async () => {
const getVersion = defineRpcFunction({
name: 'test:agent-version',
type: 'static',
agent: { description: 'Return the current version.' },
handler: () => '1.0.0',
})

const result = await collectStaticRpcDump([getVersion], {})
const expectedPath = `${DEVFRAME_RPC_DUMP_DIRNAME}/test~agent-version.static.json`

expect(getVersion.jsonSerializable).toBe(true)
expect(result.manifest['test:agent-version']).toEqual({
type: 'static',
path: expectedPath,
serialization: 'json',
})
expect(result.files[expectedPath]?.serialization).toBe('json')
})

it('collects static rpc output into sharded file entries', async () => {
const getVersion = defineRpcFunction({
name: 'test:get-version',
Expand Down
2 changes: 2 additions & 0 deletions packages/devframe/src/rpc/dump/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { RpcDumpRecord, RpcFunctionDefinitionAny } from '../types'
import {
DEVFRAME_RPC_DUMP_DIRNAME,
} from 'devframe/constants'
import { ensureAgentJsonSerializable } from '../agent-json-serialization'
import { getRpcHandler } from '../handler'
import { dumpFunctions } from './collect'

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

for (const definition of definitions) {
ensureAgentJsonSerializable(definition)
const type = definition.type ?? 'query'
const serialization: StaticRpcDumpSerialization
= definition.jsonSerializable === true ? 'json' : 'structured-clone'
Expand Down
14 changes: 14 additions & 0 deletions packages/devframe/src/rpc/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ function schema<Input, Output = Input>(): StandardSchemaV1<Input, Output> {
}

describe('rpcFunctionDefinitionToFunction', () => {
it('requires args and returns schemas together', () => {
// @ts-expect-error args and returns schemas must be provided together
defineRpcFunction({
name: 'missingReturns',
args: [v.string()],
})

// @ts-expect-error args and returns schemas must be provided together
defineRpcFunction({
name: 'missingArgs',
returns: v.string(),
})
})

it('should infer types from generic parameters when no schemas', () => {
const fn = defineRpcFunction({
name: 'noSchema',
Expand Down
Loading
Loading