Skip to content

Without MVA vs With MVA

Prerequisites

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

Every tool response in a raw MCP server is JSON.stringify() — the AI gets a flat blob and guesses what it means. Vurb.ts's MVA pattern replaces guessing with a structured perception package: validated data + domain rules + UI blocks + suggested next actions.

This also makes it straightforward to wrap existing REST or SOAP APIs into structured MCP tools without rebuilding your backend. The resulting server works with every MCP client: Cursor, Claude Desktop, Claude Code, Windsurf, Cline, and VS Code with GitHub Copilot.

AspectWithout MVAWith MVA
Tool count50 individual tools. Token explosion.Action consolidation — 5,000+ ops behind ONE tool via module.action discriminator
Response formatJSON.stringify() — AI parses and guessesStructured perception package — validated data + rules + UI + affordances
Domain contextNone. amount_cents: 45000 — dollars? cents?System rules travel with data: "amount_cents is in CENTS. Divide by 100."
Next actionsAI hallucinates tool namesAgentic HATEOAS — .suggest() / .suggestActions() based on data state
Large datasets10,000 rows dump — token DDoS.limit(50) / .agentLimit(50) truncates and teaches filters
SecurityInternal fields leakSchema as boundary — .strict() rejects undeclared fields
ReusabilitySame entity rendered differently per toolPresenter defined once, reused everywhere
ChartsText onlyUI Blocks — ECharts, Mermaid, summaries server-side
Routingswitch/case with hundreds of branchesHierarchical groups — platform.users.list
ValidationManual if (!args.id)Zod schema at framework level
Error recoverythrow new Error('not found') — AI gives uptoolError() with recovery hints and retry args
MiddlewareCopy-paste auth checkstRPC-style defineMiddleware() with context derivation
Cache signalsNone — AI re-fetches stale data foreverState sync — RFC 7234-inspired temporal awareness
DeploymentStdio only — manual HTTP bridgingOne-line adapters for Vercel Edge, Cloudflare Workers, and AWS Lambda
Code generationWrite every tool by handOpenAPI Generator turns any spec into a typed MCP server. Prisma Generator creates CRUD tools from schema.
IntegrationsBuild connectors from scratchn8n bridge exposes workflows as tools. OAuth Device Flow for enterprise auth.
Type safetyManual castingcreateVurbClient() with end-to-end inference

Before & After: Invoice

Without MVA:

typescript
server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { name, arguments: args } = request.params;
    if (name === 'get_invoice') {
        const invoice = await db.invoices.findUnique(args.id);
        return {
            content: [{ type: 'text', text: JSON.stringify(invoice) }]
        };
    }
    // ...50 more if/else branches
});
// AI receives: { "id": "inv_123", "amount_cents": 45000, "internal_margin": 0.12, "customer_ssn": "123-45-6789" }
// Displays $45,000 instead of $450. Internal fields leak. No next-action guidance.

With MVA:

typescript
import { createPresenter, suggest, ui } from '@vurb/core';
import { initVurb } from '@vurb/core';
import { z } from 'zod';

const f = initVurb<AppContext>();

const InvoicePresenter = createPresenter('Invoice')
    .schema(z.object({
        id: z.string(),
        amount_cents: z.number().describe('Amount in cents — divide by 100 for display'),
        status: z.enum(['paid', 'pending', 'overdue']),
    }))
    .rules([
        'CRITICAL: amount_cents is in CENTS. Divide by 100 for display.',
        'Always show currency as USD.',
    ])
    .ui((inv) => [
        ui.echarts({
            series: [{ type: 'gauge', data: [{ value: inv.amount_cents / 100 }] }]
        }),
    ])
    .suggest((inv) =>
        inv.status === 'pending'
            ? [suggest('billing.pay', 'Invoice is pending — process payment')]
            : [suggest('billing.archive', 'Invoice is settled — archive it')]
    );

const getInvoice = f.query('billing.get_invoice')
    .describe('Get an invoice by ID')
    .withString('id', 'Invoice ID')
    .returns(InvoicePresenter)
    .handle(async (input, ctx) => ctx.db.invoices.findUnique(input.id));
// AI receives: system rules + validated data (no internal fields) + ECharts gauge + suggested actions

Before & After: Users

Without MVA:

typescript
case 'list_users':
    const users = await db.users.findMany();
    return { content: [{ type: 'text', text: JSON.stringify(users) }] };
    // 10,000 users × ~500 tokens = context DDoS

With MVA:

typescript
const UserPresenter = createPresenter('User')
    .schema(z.object({ id: z.string(), name: z.string(), role: z.string() }))
    .limit(50)
    .suggest(() => [
        suggest('users.search', 'Search by name or role for specific users'),
    ]);
// 50 users shown. Agent guided to filters. ~25,000 tokens instead of ~5,000,000.

Before & After: Error Recovery

Without MVA:

typescript
if (!invoice) {
    return { content: [{ type: 'text', text: 'Invoice not found' }], isError: true };
}
// AI: "I encountered an error." (no idea what to try differently)

With MVA:

typescript
if (!invoice) {
    return toolError('NOT_FOUND', {
        message: `Invoice ${args.id} not found`,
        recovery: { action: 'list', suggestion: 'List invoices to find the correct ID' },
        suggestedArgs: { status: 'pending' },
    });
}
// AI: "Invoice not found. Let me list pending invoices to find the right one."

The Architecture Difference

text
Without MVA:                          With MVA:
┌──────────┐                          ┌──────────┐
│  Handler  │→ JSON.stringify() →     │  Handler  │→ raw data →
│           │  raw data to LLM        │           │
└──────────┘                          └──────────┘

                                      ┌──────────────────────┐
                                      │     Presenter        │
                                      │ ┌──────────────────┐ │
                                      │ │ Schema (strict)  │ │
                                      │ │ System Rules     │ │
                                      │ │ UI Blocks        │ │
                                      │ │ Agent Limit      │ │
                                      │ │ Suggest Actions  │ │
                                      │ │ Embeds           │ │
                                      │ └──────────────────┘ │
                                      └──────────────────────┘

                                      Structured Perception
                                      Package → LLM
Without MVAWith MVA
Lines of code per tool20-50 (routing + validation + formatting)3-5 (handler only)
SecurityHope you didn't forget to strip fieldsSchema IS the boundary
Token cost per callHigh (raw dumps, large payloads)Low (guardrails, TOON, truncation)
Deployment targetsStdio + manual HTTP bridgeStdio, SSE, Vercel, Cloudflare Workers
MaintenanceEvery tool re-implements renderingPresenter defined once