createMcpApp from @json-render/mcp 0.21.0 (same code on main) has two problems that together make most generated UIs arrive empty or inert in an MCP Apps host.
1. state, on and watch are stripped before the tool runs
registerJsonRenderTool passes catalog.zodSchema() as the tool's input schema. That schema describes root and elements (type, props, children, visible, repeat) and nothing else. The MCP SDK parses tool arguments with it, so a spec's top-level state and each element's on and watch are removed before the handler sees them. The catalog prompt tells the model to always send state for data-backed UI, so tables and lists render empty and buttons do nothing.
Props are not checked either: an unknown prop passes, and when catalog.validate fails, the handler returns the spec anyway with no error, so the model never learns what it got wrong.
Repro (@json-render/mcp@0.21.0, @json-render/core@0.21.0, @json-render/react@0.21.0):
import { createMcpApp } from '@json-render/mcp';
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
components: {
Text: { props: z.object({ text: z.string() }), description: 'Text' },
Button: { props: z.object({ label: z.string() }), description: 'Button' },
},
actions: {},
});
const server = await createMcpApp({ name: 'repro', version: '0.0.0', catalog, html: '<p>view</p>' });
const client = new Client({ name: 'c', version: '0' });
const [a, b] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(b), client.connect(a)]);
const result = await client.callTool({
name: 'render-ui',
arguments: {
spec: {
root: 'main',
state: { name: 'Ada' },
elements: {
main: { type: 'Text', props: { text: { $state: '/name' } }, children: ['go'] },
go: {
type: 'Button',
props: { label: 'Go', bogus: 1 },
on: { press: { action: 'setState', params: { statePath: '/name', value: 'Bob' } } },
watch: { '/name': { action: 'setState', params: { statePath: '/x', value: 1 } } },
children: [],
},
},
},
},
});
console.log(result.content[0].text, result.isError ?? false);Output:
{"root":"main","elements":{"main":{"type":"Text","props":{"text":{"$state":"/name"}},"children":["go"]},"go":{"type":"Button","props":{"label":"Go","bogus":1},"children":[]}}} falsestate, on and watch are gone, and bogus went through.
2. The tool description buries the catalog
The default description is catalog.prompt(), written as a system prompt for a model that streams JSON Patch. For the two-component catalog above it is 15 521 characters, and the component list starts at character 5 372. In our testing, claude.ai showed the model a tool description cut off after roughly 2 000 characters, so the model saw patch-streaming instructions and no component, prop or action, and guessed ("variant": "primary" on a Button the catalog did not define). tool.description can be overridden, but the default is what most people ship.
What fixed both for us
In ngx-json-render/mcp/server (Apache-2.0, same tool name and ui:// resource as createMcpApp):
- An input schema built from the catalog:
state,on,watch,visibleandrepeatdeclared; each component's props typed from its catalog schema, with dynamic values ({ "$state": … }) allowed and unknown props rejected; each action binding checked against the built-in and catalog actions. Shared parts are JSON Schema definitions, so a 33-component catalog comes to about 44 000 characters. - A short description (about 1 600 characters) that covers what a schema cannot say (state, bindings, repeat, events) and leaves the vocabulary to the input schema.
- A spec that fails
validateSpecgoes back asisError: truewith the problems listed, so the model can fix it.
Happy to open a PR along these lines if you would take one, or to adapt it to a different direction you prefer.
Activity
0No activity yet.