Skip to content

The MVA Pattern

Prerequisites

Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.

TELL YOUR AI AGENT
"Rewrite my raw MCP handler using the MVA pattern — Model for data, Presenter for agent perception, middleware for auth."

THE PROBLEM
MVC wasn't built for agents.
Every guess is a hallucination.
When a tool returns { amount_cents: 45000 }, the agent guesses: cents or dollars? Offer a payment action? What visualization?
Context Starvation
Data without rules. 45000 displays as dollars instead of cents.
Action Blindness
No affordances → hallucinated tool names and skipped workflows.
Perception Drift
Same entity, different tools, contradictory behavior.

THE SOLUTION
Model → View → Agent.
Deterministic perception.

The MVA Flow

01
Model — Domain Data
Defined with defineModel(). Declares field types, defaults, fillable profiles, hidden and guarded fields. One source of truth.
02
View — Presenter
Shapes perception with rules, UI blocks, affordances, and Zod validation. Domain-level, not tool-level. Define InvoicePresenter once — every tool reuses it.
03
Agent — LLM/AI
Receives structured context — not raw data. The agent follows explicit affordances instead of guessing. Claude, GPT, Gemini, or any MCP client.

The Model

models/InvoiceModel.ts
typescript
import { defineModel } from '@vurb/core';

export const InvoiceModel = defineModel('Invoice', m => {
  m.casts({
    id:           m.string('Invoice identifier'),
    amount_cents: m.number('Amount in cents — divide by 100 for display'),
    status:       m.enum('Payment status', ['paid', 'pending', 'overdue']),
  });

  m.hidden(['tenant_id']);         // never reaches the agent
  m.guarded(['id']);               // never mass-assignable
  m.fillable({
    create: ['amount_cents', 'status'],
    update: ['status'],
  });
});

Fields not in m.casts() or excluded via m.hidden() never reach the agent. .describe() annotations auto-extract as system rules.


The Presenter

RULES
System Rules
Travel with the data, not in a global prompt. Context-aware — adapt to user role, tenant, locale.
UI
UI Blocks
Charts, tables, markdown, diagrams. Single items use .ui(), arrays use .collectionUi().
LIMIT
Cognitive Guardrails
Truncate arrays before validation. Without this, 10,000 rows dump into the context window.
HATEOAS
Affordances
Data-driven next actions. No hallucinated tool names, no skipped workflows.

Context-Aware Rules

dynamic rules — RBAC-aware
typescript
const InvoicePresenter = createPresenter('Invoice')
  .schema(InvoiceModel)
  .rules((invoice, ctx) => [
    'CRITICAL: amount_cents is in CENTS. Divide by 100.',
    ctx?.user?.role !== 'admin'
      ? 'RESTRICTED: Mask financial totals for non-admin users.'
      : null,
    `Format dates using ${ctx?.tenant?.locale ?? 'en-US'}.`,
  ]);

When the agent works with users or orders, invoice rules aren't loaded. This is Context Tree-Shaking.

Affordances

HATEOAS — state-driven actions
typescript
import { createPresenter, suggest } from '@vurb/core';

const InvoicePresenter = createPresenter('Invoice')
  .schema(InvoiceModel)
  .suggest((invoice) => [
    suggest('billing.pay', 'Process immediate payment'),
    invoice.status === 'overdue'
      ? suggest('billing.escalate', 'Escalate to collections')
      : null,
  ].filter(Boolean));

Composition

nested Presenters
typescript
const ClientPresenter = createPresenter('Client')
  .schema(clientSchema)
  .rules(['Display company name prominently.']);

const InvoicePresenter = createPresenter('Invoice')
  .schema(InvoiceModel)
  .rules(['amount_cents is in CENTS.'])
  .embed('client', ClientPresenter);

ClientPresenter's rules and UI blocks merge automatically. Define once — reuse across InvoicePresenter, OrderPresenter, ContractPresenter.

Pipeline Integration

tool + Presenter = MVA
typescript
const getInvoice = f.query('billing.get_invoice')
  .describe('Get an invoice by ID')
  .withString('invoice_id', 'The exact invoice ID')
  .returns(InvoicePresenter)
  .handle(async (input, ctx) => {
    return await ctx.db.invoices.findUnique({
      where: { id: input.invoice_id },
      include: { client: true },
    });
  });

The handler (Model) produces raw data. The Presenter (View) shapes perception. The LLM (Agent) acts on structured context.

TIP

The handler can return raw data directly — FluentToolBuilder.handle() auto-wraps non-ToolResponse returns with success().


Deep Dives

Cookbook Recipes