mcp-rune 0.107.0
Be star #1 Get started
SECTION II · GUIDE 07 OF 49
Reading
8 min
Topic
models · reference
Spec
v0.107.0
Source
02-the-model/attributes-and-kinds.md
On this page12 sections

Attributes and kinds

The previous chapter showed the attributes block as a flat map of names to definitions. This chapter zooms into one entry of that map. The type: literal you wrote ('string', 'enum', 'datetime') selects a kind — a single object that knows how the value moves between your API, the framework’s internal representation, the HTML <input>, the LLM-facing summary, and the validator. One declaration drives all of them.

Try it — watch three representations move

Verified against rune CLI 0.11.0 · @mcp-rune/mcp-rune 0.107.0 · Node 24.

Three calls against your bookshelf-tour project surface three of the representations the diagram below maps. Run them and match the output before reading the kind taxonomy.

1. API representation — list_models shows the wire shape

list_models with {} reports the attribute names and types the framework will accept on the wire. For the scaffolded book:

{
  "name": "book",
  "attributes": ["name", "description"],
  "required_attributes": ["name"]
}

name is a string kind, description is a text kind — both selected by the type: literal in src/models/book.ts.

2. Validation representation — validate_form exercises the kind’s validate method

Call validate_form with { "model": "book", "fields": {} }:

{
  "valid": false,
  "ready_to_submit": false,
  "errors": [{ "field": "name", "message": "Name is required" }],
  "warnings": [],
  "computed": {},
  "fields": {}
}

The error message is shaped by two things: the required: true flag on the name attribute (the rule) and the attribute’s description field (the "Name" label). Change the description: text in book.ts and the message updates on next boot.

3. LLM representation — get_prompt_guide calls every kind’s describe

Call get_prompt_guide with { "guide_name": "book" }. The bottom of the response is a Markdown table the LLM reads to fill the form:

| Attribute     | Type   | Required | Valid Values |
|---------------|--------|----------|--------------|
| name          | string | Yes      |              |
| description   | text   | No       |              |

Each row is the kind reporting itself. Switch description from 'text' to a different built-in (e.g. 'string') in src/models/book.ts, save, re-run, and watch the row update — no template lives in your prompt class for this content.

Observe: one type: literal selects the wire parser, the validation message, and the LLM-facing type label simultaneously. Now the rest of this guide is the taxonomy of which kinds you have to choose from and what each one ships.


An attribute kind describes how a single attribute value moves through three representations:

API value  ⇄  internal value  ⇄  HTML <input> value
   (parse / serialize)        (fromInput / toInput)
   describe(internal) -> string   (LLM-facing summary)
   validate(internal) -> string | null   (kind-aware errors)
   render(internal)  -> DOM Node   (display rendering, browser only)

A concrete walk-through — an ISBN attribute declared as string:isbn — makes the boundaries obvious:

ATTRIBUTE KIND · CONVERSION HUBAPI JSON{ isbn: "9780132350884" }HTML form input<input value="978…">Internal value"9780132350884" · normalized, hyphens outparse(api)toInput(internal)serializeAPI JSON{ isbn: "…" }describe"ISBN-13: 978-0-13-235088-4"LLM-facing · humanizedvalidate"ok" | null | errorformat<code>9780132350884</code>DOM node · browser only

Every kind plugs into the same six methods. Override one, all, or none — defaults fall back to String(value).

mcp-rune ships 17 built-in kinds (string, integer, boolean, date, enum, array, email, url, uuid, json, color, rating, …). The same kind taxonomy is used by:

  • The polymorphic form generator to pick the right <input type="…">.
  • The iframe display layer (find-model-app, show-model-app, view-selection-app) to render cells.
  • The prompt system to set the type label LLMs see in attribute-reference tables.
  • The validate_form tool to reject malformed inputs (uuid, email, date, json, …) before they hit the API.
  • The server-side prompt summary so the LLM’s mental model of the form state matches what the user just saw on screen (boolean → Yes/No, enum → humanized label, base64 → (binary)).

A deployer extends this taxonomy through a single declarative channel — the kinds: option on AppRegistry — and both browser and server pick the change up automatically.

Table of Contents

Built-in Kinds

All 17 kinds are registered in src/mcp/models/kinds/ — one file per built-in kind. Browser-side display rendering (render()) for kinds whose output is more than helpers.text(String(value)) lives in src/mcp/apps/shared/kind-renderers.ts.

KindhtmlInputTypepromptTypedescribe example
stringtextstringClean Code
texttextareatextA book about software craft.
integernumberinteger42
decimalnumbernumber3.14
booleancheckboxbooleanYes / No
datedatedate2026-05-28
datetimedatetime-localdatetime2026-05-28T14:30:00.000Z
timetimetime14:30
enumselectenumIn Progress (humanized)
arraytextarrayPhysical, Pdf (humanized join)
uuidtextuuid550e8400-e29b-41d4-…
jsontextareaobject{"foo":1}
colorcolorstring#0a84ff
emailemailstringalice@example.com
urlurlstringhttps://example.com
base64textstring(binary)
ratingnumberinteger3/5

The htmlInputType column is the kind’s own default; any attribute definition carrying enumValues widens its input through resolveInputType regardless of kind — select for scalar kinds, checkbox_group when the kind is array.

describe is the LLM-facing summary — it’s what shows up in get_form_summary and in the human-readable section of validate_form responses. It deliberately humanizes (Yes/No, not true/false; In Progress, not in_progress) so the LLM’s mental model of the form state matches what the user saw on screen.

The KindDescriptor Contract

examples/kind-descriptor-contract.ts
import type { KindDescriptor, KindOpts } from '@mcp-rune/mcp-rune/models'

interface KindOpts {
  format?: string
  enumValues?: string[]
  max?: number
}

interface KindDescriptor {
  htmlInputType: string
  promptType: string
  label: string
  describe(value: unknown, opts?: KindOpts): string
  validate(value: unknown, opts?: KindOpts): string | null
  parse(api: unknown, opts?: KindOpts): unknown
  serialize(internal: unknown, opts?: KindOpts): unknown
  toInput(internal: unknown, opts?: KindOpts): string
  fromInput(raw: string, opts?: KindOpts): unknown
}
/**
 * Per-attribute kind configuration. Passed to every KindDescriptor method
 * so kinds can specialize (e.g. `format: 'iso8601'` for dates).
 *
 * @typedef {Object} KindOpts
 * @property {string} [format]
 * @property {string[]} [enumValues]
 * @property {number} [max]
 */

/**
 * The contract a kind implementation satisfies. Each method receives the
 * value plus the per-attribute opts; together they handle the three
 * representations (wire / validation / render).
 *
 * @typedef {Object} KindDescriptor
 * @property {string} htmlInputType
 * @property {string} promptType
 * @property {string} label
 * @property {(value: unknown, opts?: KindOpts) => string} describe
 * @property {(value: unknown, opts?: KindOpts) => string | null} validate
 * @property {(api: unknown, opts?: KindOpts) => unknown} parse
 * @property {(internal: unknown, opts?: KindOpts) => unknown} serialize
 * @property {(internal: unknown, opts?: KindOpts) => string} toInput
 * @property {(raw: string, opts?: KindOpts) => unknown} fromInput
 */

Each method has a precise role. Read them as a contract between three caller groups:

  • src/mcp/apps/lib/app-form-schema.ts (server) resolves each field’s widget through resolveInputType(attr.type, attr.format, kindOptsFrom(attr)) when generating FormFieldDefinition.type, so the iframe knows which <input> widget to render.
  • schema-derivation.ts (server) reads promptType to populate the “Type” column of LLM-facing attribute reference tables.
  • shared/model-form/main.js (iframe) calls parse(apiValue) to hydrate the form from a record, then toInput(internal) to write the <input value="…">. On submit, it calls fromInput(rawString) then serialize(internal) to produce the API payload.
  • find-model-app-ui / show-model-app-ui / view-selection-app-ui call parse then render (the DOM-returning function from kind-renderers.ts, which is layered on top of the descriptor and not part of it).
  • HybridFormStrategy.generateSummary (server) delegates to the DefaultFormSummaryRenderer, which calls getKind().describe for every populated field so the LLM-facing summary mirrors the iframe’s rendering.
  • BaseFormStrategy.validateField (server) calls validate(value) for kind-aware error messages. Range / length / pattern checks from FieldValidation are orthogonal and live in src/mcp/prompts/form-strategies/base-form-strategy.ts.

parse / serialize / toInput / fromInput follow the standard rule: parse accepts whatever the API gave you (string, number, boolean, null); toInput produces a value the HTML <input> is happy with (always a string); fromInput accepts the raw <input>.value string; serialize returns the shape your API expects.

Two Extension Paths

PathWhen to useChannel
AppRegistry({ kinds })New kind your deployment needs (ISBN, currency, phone), or override fields on a built-in.kinds: Record<string, KindExtension> — descriptor half registers server-side; render half flows into the iframe.
registerKindRenderer(...)DOM-only widget change for an existing kind (slider for rating, gradient swatch for color).registerKindRenderer(kind, { render }, { format }) from @mcp-rune/mcp-rune/apps/kind-renderers. Iframe-only.

AppRegistry({ kinds }) is the canonical path — one entry per kind covers behavior (parse, validate, label, htmlInputType, promptType) AND rendering. registerKindRenderer exists only for DOM widget overrides on a kind already defined elsewhere.

The KindExtension shape

examples/kind-extension-shape.ts
import type { KindExtension, KindDescriptor, KindRenderHint } from '@mcp-rune/mcp-rune/apps'

// KindExtension = Partial<KindDescriptor> & { render?: KindRenderHint }
//
// Partial<KindDescriptor>: label, htmlInputType, promptType, plus the
//   functions parse / serialize / toInput / fromInput / describe / validate.
//   These run server-side.
//
// KindRenderHint: declarative DOM rendering forwarded to the iframe runtime
//   through `window.__MCP_RUNE_KIND_RENDERERS__`. A closed allowlist of
//   operations (template, Intl locale, badge variant) so the channel is
//   CSP-safe — no inline JS is ever executed.

interface KindRenderHint {
  template?: string // "{value}" substitution
  locale?: string // Intl.DateTimeFormat
  dateStyle?: 'full' | 'long' | 'medium' | 'short'
  timeStyle?: 'full' | 'long' | 'medium' | 'short'
  badge?: { icon?: string; className?: string }
}
/**
 * KindExtension = Partial<KindDescriptor> & { render?: KindRenderHint }
 *
 * Partial<KindDescriptor>: label, htmlInputType, promptType, plus the
 *   functions parse / serialize / toInput / fromInput / describe / validate.
 *   These run server-side.
 *
 * KindRenderHint: declarative DOM rendering forwarded to the iframe runtime
 *   through `window.__MCP_RUNE_KIND_RENDERERS__`. A closed allowlist of
 *   operations (template, Intl locale, badge variant) so the channel is
 *   CSP-safe — no inline JS is ever executed.
 *
 * @typedef {Object} KindRenderHint
 * @property {string} [template]   `{value}` substitution
 * @property {string} [locale]     Intl.DateTimeFormat
 * @property {'full' | 'long' | 'medium' | 'short'} [dateStyle]
 * @property {'full' | 'long' | 'medium' | 'short'} [timeStyle]
 * @property {{ icon?: string, className?: string }} [badge]
 */

The descriptor half (functions and metadata) registers with src/mcp/models/kinds/ at AppRegistry construction time and runs on the server. Only the render hint serializes into the iframe via AppRegistry.injectIntoHead as window.__MCP_RUNE_KIND_RENDERERS__. One config entry, one mental model.

getKind Lookup Rules

examples/attribute-kinds-guide-03.ts
import { getKind } from '@mcp-rune/mcp-rune/models'

getKind('string', 'isbn') // 1. exact kind:format narrowing
getKind('string', 'url') // 2. format hop — 'url' is a top-level kind
getKind('uuid') // 3. base kind
getKind('totally-fake') // 4. throws UnknownKindError
import { getKind } from '@mcp-rune/mcp-rune/models'
getKind('string', 'isbn') // 1. exact kind:format narrowing
getKind('string', 'url') // 2. format hop — 'url' is a top-level kind
getKind('uuid') // 3. base kind
getKind('totally-fake') // 4. throws UnknownKindError

Both arguments are case-insensitive (getKind('URL') and getKind('url') are the same). The format-hop fallback (step 2) is what makes JSON-schema-style format: 'email' or format: 'date-time' work without each deployer registering every narrowing. Boot validation (validateRegistries) catches every model-driven call site before the throw can surface at runtime; the iframe display layer instead degrades unknown kinds to string semantics with a console warning (ADR 0010).

Worked Example: ISBN as string:isbn

Suppose your books have an isbn attribute and you want:

  • A text input with an ISBN pattern check on submit.
  • The display layer to prefix ISBN: before the value.
  • The LLM-facing prompt docs to say “ISBN” instead of “string.”
  • A clear label in the form.
src/config.ts
// your-server/config.ts
import { createDefaultAppRegistry } from '@mcp-rune/mcp-rune/apps'
import { MODEL_CLASSES } from './models'

export const appRegistry = createDefaultAppRegistry({
  modelClasses: MODEL_CLASSES,
  namespace: 'bookshelf',
  kinds: {
    'string:isbn': {
      label: 'ISBN',
      htmlInputType: 'text',
      promptType: 'string',
      validate: (v) => {
        if (v === null || v === undefined || v === '') return null
        const s = String(v)
        if (s.length < 10 || s.length > 17) return 'ISBN must be 10–17 characters'
        return /^(?:97[89][- ]?)?(?:\d[- ]?){9}[\dX]$/.test(s) ? null : 'invalid ISBN'
      },
      render: { template: 'ISBN: {value}' }
    }
  }
})
// your-server/config.js
import { createDefaultAppRegistry } from '@mcp-rune/mcp-rune/apps'
import { MODEL_CLASSES } from './models'
export const appRegistry = createDefaultAppRegistry({
  modelClasses: MODEL_CLASSES,
  namespace: 'bookshelf',
  kinds: {
    'string:isbn': {
      label: 'ISBN',
      htmlInputType: 'text',
      promptType: 'string',
      validate: (v) => {
        if (v === null || v === undefined || v === '') return null
        const s = String(v)
        if (s.length < 10 || s.length > 17) return 'ISBN must be 10–17 characters'
        return /^(?:97[89][- ]?)?(?:\d[- ]?){9}[\dX]$/.test(s) ? null : 'invalid ISBN'
      },
      render: { template: 'ISBN: {value}' }
    }
  }
})

Declare the kind on the model attribute:

src/book.ts
class Book extends BaseModel {
  static attributes = {
    isbn: { type: 'string', format: 'isbn', label: 'ISBN' }
    // …
  }
}
class Book extends BaseModel {
  static attributes = {
    isbn: { type: 'string', format: 'isbn', label: 'ISBN' }
    // …
  }
}

Now:

  • show-model-app-ui and find-model-app-ui render ISBN: 978-0-13-235088-4.
  • new-model-app / edit-model-app render <input type="text"> for the create/update form (via shared shared/model-form/main.js).
  • validate_form rejects values that don’t match the pattern or are outside 10–17 chars.
  • The prompt’s attribute reference table says ISBN in the “Type” column.

One declaration, four surfaces.

Worked Example: Currency Kind

Custom parse / serialize logic is just function fields on the same KindExtension. No separate “imperative” path:

examples/attribute-kinds-guide-06.ts
// your-server/registries/app-registry.ts
import { createDefaultAppRegistry } from '@mcp-rune/mcp-rune/apps'

export const appRegistry = createDefaultAppRegistry({
  modelClasses: MODEL_CLASSES,
  namespace: 'invoicing',
  kinds: {
    currency: {
      label: 'Currency',
      htmlInputType: 'number',
      promptType: 'number',
      parse: (v) => {
        if (v === null || v === undefined || v === '') return null
        if (typeof v === 'number') return v
        return Number(String(v).replace(/[^0-9.-]/g, ''))
      },
      serialize: (v) => (typeof v === 'number' ? Math.round(v * 100) / 100 : null),
      describe: (v) => {
        if (v === null || v === undefined) return ''
        return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
          Number(v)
        )
      },
      toInput: (v) => (v === null || v === undefined ? '' : String(v)),
      fromInput: (v) => (v === '' ? null : Number(v)),
      validate: (v) => {
        if (v === null || v === undefined || v === '') return null
        return typeof v === 'number' && Number.isFinite(v) ? null : 'must be a number'
      }
    }
  }
})
// your-server/registries/app-registry.js
import { createDefaultAppRegistry } from '@mcp-rune/mcp-rune/apps'
export const appRegistry = createDefaultAppRegistry({
  modelClasses: MODEL_CLASSES,
  namespace: 'invoicing',
  kinds: {
    currency: {
      label: 'Currency',
      htmlInputType: 'number',
      promptType: 'number',
      parse: (v) => {
        if (v === null || v === undefined || v === '') return null
        if (typeof v === 'number') return v
        return Number(String(v).replace(/[^0-9.-]/g, ''))
      },
      serialize: (v) => (typeof v === 'number' ? Math.round(v * 100) / 100 : null),
      describe: (v) => {
        if (v === null || v === undefined) return ''
        return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
          Number(v)
        )
      },
      toInput: (v) => (v === null || v === undefined ? '' : String(v)),
      fromInput: (v) => (v === '' ? null : Number(v)),
      validate: (v) => {
        if (v === null || v === undefined || v === '') return null
        return typeof v === 'number' && Number.isFinite(v) ? null : 'must be a number'
      }
    }
  }
})

At AppRegistry construction the descriptor half registers with the shared kind registry; server-side prompt summaries, form-schema generation, and validate_form all pick it up immediately. Boot validation runs automatically after construction (createDefaultAppRegistry and createServer both run validateRegistries with a validate: 'throw' | 'warn' | 'off' policy), so deployer-defined kinds are registered by the time model attribute types are checked.

The framework also exports registerKind(...) from @mcp-rune/mcp-rune/models for tests and framework-internal callers. Application code should configure kinds through AppRegistry({ kinds }) so there’s one extension point to find.

Where each half runs

The behavior half of a KindExtension (parse / serialize / validate / describe / toInput / fromInput) runs server-side only. App iframes bake the built-in kind registry into their bundles at build time, and deployer functions never cross the CSP-safe channel into them — what crosses is data: the resolved form schema and any declarative render hints.

Inside the apps, a top-level custom kind like currency therefore:

  • renders cells with your render hint when you declared one — the recommended path for custom display;
  • degrades to string semantics (raw value display, passthrough input handling) when no hint exists, with a one-time console warning rather than a crashed cell or a dead form;
  • keeps your full custom behavior on every server surfacevalidate_form, form-schema generation, prompt summaries — regardless.

When the form inputs should inherit a built-in’s handling (number coercion for currency, say), declare the attribute as a kind:format narrowing instead — invoice_total: { type: 'decimal', format: 'currency' } with kinds: { 'decimal:currency': { … } }. The server applies your narrowed descriptor end-to-end; the iframe, which cannot see it, falls back to the base decimal kind for input handling and to your render hint for display. ADR 0010 records the full design.

Worked Example: DOM-Only Override

Sometimes you want to keep a kind’s contract intact but change the rendered widget. Example: render rating as a slider instead of stars.

src/apps/rating-slider.ts
// your-server/apps/rating-slider.ts
import { registerKindRenderer, helpers } from '@mcp-rune/mcp-rune/apps/kind-renderers'

registerKindRenderer('rating', {
  render: (value, opts) => {
    const max = opts?.column?.max ?? 5
    const span = document.createElement('span')
    span.textContent = `${value}/${max}`
    span.style.background = 'linear-gradient(to right, gold, gold)'
    span.style.backgroundSize = `${(Number(value) / max) * 100}% 100%`
    span.style.backgroundRepeat = 'no-repeat'
    return span
  }
})
// your-server/apps/rating-slider.js
import { registerKindRenderer } from '@mcp-rune/mcp-rune/apps/kind-renderers'
registerKindRenderer('rating', {
  render: (value, opts) => {
    const max = opts?.column?.max ?? 5
    const span = document.createElement('span')
    span.textContent = `${value}/${max}`
    span.style.background = 'linear-gradient(to right, gold, gold)'
    span.style.backgroundSize = `${(Number(value) / max) * 100}% 100%`
    span.style.backgroundRepeat = 'no-repeat'
    return span
  }
})

registerKindRenderer is DOM-only. It accepts only { render: (value, opts) => Node } — anything without a render function throws. parse, serialize, and the other descriptor fields belong in the kind descriptor (configured via AppRegistry({ kinds })). The deliberate narrowing prevents drift where the server-side prompts and validation diverge from what the iframe rendered.

To register a brand-new kind, use AppRegistry({ kinds }) above. registerKindRenderer only overrides the DOM rendering of an existing kind.

How a Kind Flows Through the System

Trace a single published_at attribute (type: 'datetime') from definition to LLM and back:

  1. Attribute definition on the model: published_at: { type: 'datetime', description: 'Publish timestamp' }.

  2. Prompt docs: schema-derivation.ts calls getKind('datetime').promptType'datetime'. Renders in the LLM-facing attribute reference table.

  3. Form schema: src/mcp/apps/lib/app-form-schema.ts calls resolveInputType(attr.type, attr.format, kindOptsFrom(attr))'datetime-local'. Renders as <input type="datetime-local"> in the form-app iframes.

  4. Form prefill: shared/model-form/main.js receives an API value "2026-05-28T14:30:00Z", calls getKindRenderer('datetime').parse(...)Date, then .toInput(date)"2026-05-28T14:30". Sets the <input value="…">.

  5. Cell rendering in find-model-app: renderCellValue(apiValue, column) calls getKindRenderer('datetime').render(parsedDate) → a <span> with a localized “May 28, 2026, 2:30 PM” (locale overridable via kinds.datetime.render.locale).

  6. Form submit: user changes the input to "2026-06-01T09:00". shared/model-form/main.js calls getKindRenderer('datetime').fromInput(raw)Date, then .serialize(date)"2026-06-01T09:00:00.000Z". Sent to the API.

  7. validate_form: if the user instead pasted "garbage", BaseFormStrategy.validateField calls getKind('datetime').validate('garbage')'must be a valid datetime'. The tool returns the error before any API call.

  8. LLM summary: when get_form_summary runs, HybridFormStrategy.generateSummary (through the DefaultFormSummaryRenderer) calls getKind('datetime').describe(date) → full ISO string. The LLM sees the same value the iframe just displayed (in ISO form for unambiguous downstream reasoning).

Same kind, eight call sites, one descriptor.

Testing Custom Kinds

Test the descriptor’s pure functions directly:

examples/attribute-kinds-guide-09.ts
import { getKind } from '@mcp-rune/mcp-rune/models'
import './kinds/currency' // ← side-effect import to register

describe('currency kind', () => {
  it('parses string with $ sign to number', () => {
    expect(getKind('currency').parse('$1,234.56')).toBe(1234.56)
  })

  it('describe humanizes for LLM summary', () => {
    expect(getKind('currency').describe(1234.5)).toBe('$1,234.50')
  })

  it('validate rejects non-numbers', () => {
    expect(getKind('currency').validate('not a number')).toBe('must be a number')
  })
})
import { getKind } from '@mcp-rune/mcp-rune/models'
import './kinds/currency' // ← side-effect import to register
describe('currency kind', () => {
  it('parses string with $ sign to number', () => {
    expect(getKind('currency').parse('$1,234.56')).toBe(1234.56)
  })
  it('describe humanizes for LLM summary', () => {
    expect(getKind('currency').describe(1234.5)).toBe('$1,234.50')
  })
  it('validate rejects non-numbers', () => {
    expect(getKind('currency').validate('not a number')).toBe('must be a number')
  })
})

Round-trips:

test/currency-kind.test.ts
it('round-trips through input', () => {
  const k = getKind('currency')
  const api = '$42.99'
  const internal = k.parse(api)
  const inputValue = k.toInput(internal)
  const back = k.fromInput(inputValue)
  expect(k.serialize(back)).toBe(42.99)
})
it('round-trips through input', () => {
  const k = getKind('currency')
  const api = '$42.99'
  const internal = k.parse(api)
  const inputValue = k.toInput(internal)
  const back = k.fromInput(inputValue)
  expect(k.serialize(back)).toBe(42.99)
})

The DOM render() is tested in happy-dom:

test/rating-renderer.test.ts
/**
 * @vitest-environment happy-dom
 */
import { getKindRenderer } from '@mcp-rune/mcp-rune/apps/kind-renderers'

it('rating renders a custom widget after override', () => {
  // your registerKindRenderer override must run before this test
  const node = getKindRenderer('rating').render(3, { column: { max: 5 } })
  expect(node.textContent).toBe('3/5')
})
/**
 * @vitest-environment happy-dom
 */
import { getKindRenderer } from '@mcp-rune/mcp-rune/apps/kind-renderers'
it('rating renders a custom widget after override', () => {
  // your registerKindRenderer override must run before this test
  const node = getKindRenderer('rating').render(3, { column: { max: 5 } })
  expect(node.textContent).toBe('3/5')
})

Migrating from AppRegistry.formatters

Pre-v0.79 the deployer extension was AppRegistry({ formatters }) with a FormatterDescriptor shape that mixed kind-definitional fields, iframe-only validation hints, and a never-implemented parser block. v0.79 unifies it into AppRegistry({ kinds }) with a single KindExtension per kind: descriptor fields run server-side; only render reaches the iframe.

examples/attribute-kinds-guide-12.ts
// BEFORE (pre-0.79)
formatters: {
  'string:isbn': {
    label: 'ISBN',
    htmlInputType: 'text',
    validation: { pattern: '^[0-9-]+$', minLength: 10, maxLength: 17 },
    display: { template: 'ISBN: {value}' }
  }
}

// AFTER (0.79+)
kinds: {
  'string:isbn': {
    label: 'ISBN',
    htmlInputType: 'text',
    validate: (v) =>
      typeof v === 'string' && /^[0-9-]+$/.test(v) && v.length >= 10 && v.length <= 17
        ? null
        : 'must be an ISBN',
    render: { template: 'ISBN: {value}' }
  }
}
// BEFORE (pre-0.79)
formatters: {
  'string:isbn': {
    label: 'ISBN',
    htmlInputType: 'text',
    validation: { pattern: '^[0-9-]+$', minLength: 10, maxLength: 17 },
    display: { template: 'ISBN: {value}' }
  }
}

// AFTER (0.79+)
kinds: {
  'string:isbn': {
    label: 'ISBN',
    htmlInputType: 'text',
    validate: (v) =>
      typeof v === 'string' && /^[0-9-]+$/.test(v) && v.length >= 10 && v.length <= 17
        ? null
        : 'must be an ISBN',
    render: { template: 'ISBN: {value}' }
  }
}

What changed:

  • formatters:kinds: (the option is honest about what it configures).
  • display:render: (the sub-block is honest about being DOM-only).
  • validation: { pattern, minLength, maxLength, … } → write a validate(v) function instead. Same field, runs on the server, no longer drifts from KindDescriptor.validate.
  • parser: removed — it was never wired up.
  • label, htmlInputType, promptType are no longer silently ignored — they register with the kind descriptor on the server.

A model’s attributes block is half the picture. The other half — how one model points at another — is the subject of the next chapter, Associations: belongsTo, hasMany, the foreign-key columns the framework infers from them, and what pickers, validators, and forms get for free.

Related guides: