Published Sep 25, 202613 min read
MVA, the Model View Agent Pattern: A Backend Architecture for the Era of AI Agents
MVC assumed the consumer of your interface could infer context from experience. An agent cannot. MVA swaps the View for a Presenter, a perception layer that attaches rules, guardrails and affordances to data the moment an agent reads it. The pattern, the code, the token math, and five use cases where it pays for itself.

By Renato Marinho
Founder · Vinkius
MVA stands for Model, View, Agent. It is an architecture pattern for the backend an AI agent talks to, and it exists because the consumer of your interface changed species.
For fifty years we designed interfaces for a consumer that can infer. A human reads amount_cents: 45000 and knows it is four hundred and fifty dollars, because they have seen invoices before. An agent reads the same field and has to guess. Sometimes it guesses forty five thousand dollars. Sometimes it invents a payment tool that does not exist, or skips a required workflow because nothing told it the workflow was there. None of these are prompting problems. All of them are architecture problems.
I named and wrote the pattern while building MCP Fusion, the TypeScript framework for production MCP servers, where MVA is the default way a tool gets built. This is the explanation I would give a senior engineer who asked me what MVA is, why it is not MVC with a rename, and where it actually pays for itself.
MVA in one paragraph
MVA replaces the human facing View of MVC with a Presenter: a perception layer that sits between your domain data and the language model and decides what the agent sees, how it should interpret it, and what it is allowed to do next. The Model still defines your domain data and still validates it. The Presenter wraps that data in a schema boundary, interpretation rules, server rendered visual blocks, and explicit next actions. The Agent, any LLM, receives a structured perception package instead of raw JSON, so it stops guessing and starts following instructions that travel with the data.
| Layer | What it is | Responsibility |
|---|---|---|
| Model | defineModel() | Domain data: field types, defaults, which fields are hidden from the agent, which are guarded from mass assignment |
| View | The Presenter | Perception: schema boundary, rules, UI blocks, truncation limits, next actions, PII redaction |
| Agent | Any LLM | Consumes the package, reasons, calls the next tool from the offered list |
Why MVC stops working
MVC assumed a consumer that renders visual UI, has spatial awareness, selects from visible options, and does not hallucinate the next step. An agent has none of those properties, and four structural failure modes appear the moment you point an LLM at a raw API.
Context starvation. Data arrives without interpretation rules. { amount_cents: 45000 } has no rule attached, so the model leans on the field name, and field name conventions are not reliable signal. I have seen the same field render as dollars, euros, and "processing" in three different conversations.
Action blindness. After reading the data, the agent must decide what to do next. Without affordances it invents tool names, calls billing.process_payment when the tool is billing.pay, or silently stops because it did not know an action existed.
Perception drift. The same invoice comes back shaped differently from two tools, one saying amount in dollars and state: "open", the other saying amount_cents and status: "pending". The model cannot reconcile them as one entity and starts behaving inconsistently across your own product.
Security leakage. Raw JSON carries all columns the query selected: internal_margin, tenant_id, password_hash. All of it enters the context window and can surface in the answer the user reads.
Each of these is an architecture deficit. Prompt engineering cannot fix a missing output boundary, because the prompt is not where the boundary would live.
MVA in code: the three layers
The Model declares the domain entity once. Fields you do not declare here cannot reach the agent.
import { defineModel } from '@mcpfusion/core';
export const InvoiceModel = defineModel('Invoice', m => {
m.casts({
id: m.string('Invoice identifier'),
amount_cents: m.number('Amount in cents. Divide by 100 to display.'),
status: m.enum('Payment status', ['paid', 'pending', 'overdue']),
});
m.hidden(['tenant_id']);
m.guarded(['id']);
m.fillable({
create: ['amount_cents', 'status'],
update: ['status'],
});
});
The Presenter is the View. It is defined at the domain level, not per tool, so one InvoicePresenter serves any tool that returns an invoice.
import { createPresenter, ui, suggest } from '@mcpfusion/core';
export const InvoicePresenter = createPresenter('Invoice')
.schema(InvoiceModel)
.rules((invoice, ctx) => [
'CRITICAL: amount_cents is in CENTS. Divide by 100 before display.',
ctx?.user?.role !== 'admin'
? 'RESTRICTED: Mask exact totals for non admin viewers.'
: null,
invoice.status === 'overdue'
? 'WARNING: This invoice is overdue. Mention urgency proactively.'
: null,
])
.ui((invoice) => [
ui.summary(`Invoice ${invoice.id} · ${invoice.status}`),
ui.echarts({ series: [{ type: 'gauge', data: [{ value: invoice.amount_cents / 100 }] }] }),
])
.agentLimit(50, (omitted) =>
ui.summary(`50 shown, ${omitted} hidden. Filter by status or date range.`),
)
.suggest((invoice) => [
suggest('billing.pay', 'Process immediate payment'),
invoice.status === 'overdue'
? suggest('billing.escalate', 'Escalate to collections')
: null,
].filter(Boolean));
The tool is the Agent surface. The handler returns raw data; .returns() is where the perception layer attaches.
import { initMCPFusion } from '@mcpfusion/core';
const f = initMCPFusion<AppContext>();
export 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 ctx.db.invoices.findUnique({
where: { id: input.invoice_id },
include: { client: true },
});
});
export const createInvoice = f.action('billing.create')
.describe('Create an invoice')
.fromModel(InvoiceModel, 'create')
.returns(InvoicePresenter)
.handle(async (input, ctx) => ctx.db.invoices.create({ data: input }));
.fromModel(InvoiceModel, 'create') derives the input parameters from the model's fillable profile, so the input schema and the output schema cannot drift apart. One declaration, two boundaries, both enforced at compile time.
What the agent actually receives
When the handler returns, the pipeline composes a structured perception package: up to six content blocks, in a fixed order.
Block 1 DATA {"id":"INV-001","amount_cents":45000,"status":"pending"}
(tenant_id and internal_margin rejected by the schema boundary)
Block 2 UI [echarts gauge: 450.00] with a pass through directive:
"send this block to the interface, do not redraw it"
Block 3 EMBEDS rules and blocks from ClientPresenter, merged by .embed()
Block 4 HINTS situational notes added by the handler or middleware
Block 5 RULES [DOMAIN RULES] CRITICAL: amount_cents is in CENTS...
Block 6 ACTIONS → billing.pay: Process immediate payment
→ billing.escalate: Escalate to collections
The order is not cosmetic. Language models weight the end of the context more heavily, so the interpretation rules and the available actions land last, exactly where the model is about to decide what to call. Data comes first, because it grounds the interpretation that follows.
The contrast with what a raw MCP server returns is the whole argument in one screenshot:
// raw SDK: one text block, JSON.stringify, no boundary
return {
content: [{ type: 'text', text: JSON.stringify(invoice) }],
};
// the agent receives all columns, zero rules, zero next actions
Five use cases where MVA pays for itself
1. Billing and fintech. The invoice example above is not decorative. Money is the domain where a wrong guess is a support ticket or a compliance incident. The cents rule, the role based masking rule, and the payment affordance are three lines on one Presenter, and all billing tools inherit them. A junior developer cannot ship a billing tool that forgets the divide by one hundred rule, because the rule is not in their tool.
2. Operations at scale. A tasks.list call against an unbounded table returns three thousand rows at roughly five hundred tokens each, which is about 1.5 million tokens into a context window that cannot hold them. .agentLimit(50, ...) truncates the array and appends a teaching block that names the actual filter parameters: status, assignee, sprint_id, due_before. The model's next call is a filtered query. Truncation alone would give it the same truncated page forever; truncation plus teaching gives it a self correcting loop.
3. Regulated and personal data. Healthcare, HR, and any CRM with PII need a boundary that is stronger than "please do not show this". redactPII compiles object paths like patients[*].diagnosis into masking functions, so the masked value reaches the model while the UI blocks and the rules still see the real data. Combined with m.hidden(), the fields that must never leave the database are the fields that were never declared.
4. Multi tenant SaaS. Rules in the Presenter receive the request context, so the same invoice is perceived differently by an admin and by a viewer role, and dates format in the tenant locale without a second code path. This replaces a class of per tenant prompt branching that teams usually maintain by hand and usually get wrong.
5. Approval and order workflows. Affordances are computed from the current state, not from a static list. A pending order suggests orders.approve; a shipped order does not. This is HATEOAS for tools: the model is told which transitions are legal from the state it is actually in, which removes the most common class of hallucinated tool call.
There is a sixth case worth naming because it is the one most teams are in. You already have a React dashboard, a REST API, and a database. MVA does not ask you to replace them. You keep MVC for humans, add an agent facing surface for agents, and both consume the same domain model. That dual interface pattern is where most "AI native" work actually lands, and naming it makes the migration a bounded project instead of a rewrite.
The token math
The other place MVA pays is the bill. The conventional fix for a missing perception layer is to stuff domain rules into the global system prompt, which is sent on each call whether or not the domain is active. A product with fifteen domain entities ends up with roughly two thousand tokens of rules riding on each request, and eighty seven percent of them are irrelevant on any given turn.
Context tree shaking is the term for the alternative: rules are attached to the Presenter and appear only when that domain is active. A ten turn conversation goes from about twenty thousand tokens of rules to about two thousand, and the rules that do arrive are the correct ones. The side effect matters as much as the cost: when invoice rules are absent from a tasks conversation, the model cannot misapply a divide by one hundred rule to task counts. I have seen that exact error in production, and it disappears when the rule is not in the context to begin with.
MVA versus MVC, REST, GraphQL and RPC
| Consumer | Output | Next actions | Boundary | |
|---|---|---|---|---|
| MVC | Human browser | HTML per page | Links and buttons | View template |
| REST | Client code | Raw JSON | HATEOAS links are URLs | None |
| GraphQL | Client code | Client chosen fields | None | Query level only |
| RPC | Client code | Raw typed data | None | Input only |
| MVA | AI agent | Perception package | Tool names from state | Input and output |
REST with HATEOAS is the closest ancestor and deserves the clearest distinction. HATEOAS embeds links in the response, which is the right instinct, but a link is a URL to an HTTP endpoint and an agent calls tools by name. MVA affordances return tool names with semantic reasons, computed from the current data state, alongside rules and visual blocks that REST has no place to carry. GraphQL solves which fields to fetch, which was never the bottleneck; the bottleneck is how to interpret the fields after fetching.
What MVA is not
It is not a replacement for MVC. If your consumer is a human with a browser, MVC and MVVM remain correct, and no part of MVA makes a dashboard better. It is not an agent framework either: it does not plan, does not manage memory, does not choose a model. It is the contract between your data and whatever model is reading it, and it makes that contract explicit, validated, and testable.
It is also not all or nothing. You can attach a Presenter to one tool on a legacy server this afternoon and leave the other fifty alone.
How to start
npm install @mcpfusion/core @modelcontextprotocol/sdk
npx mcpfusion create
Scaffold a project, define one model, define one Presenter, put .returns() on one query. The fastest test of whether the pattern is doing anything is to read the tool result before and after: raw JSON, then the six block package. The difference is the argument.
If you want the full walkthrough of a connector built this way, the MVA split, the Presenter, and self healing errors are explained with source code in How to Build Your Own MCP Connector. If you want the runtime side, how agent traffic stays safe when it arrives at scale, that is in How to Run AI Agents in Production Safely.
Frequently asked questions
Is MVA only for MCP? The pattern is protocol independent, and MCP Fusion is its reference implementation. Anything that puts an LLM on one end of a tool call benefits from a perception layer. MCP is simply where the boundary is most visible, because MCP servers ship raw JSON by default.
Does MVA replace MVC? No, and the dual interface pattern is the honest framing. Keep MVC for your human dashboard, add MVA for your agent surface, share the domain model between them.
What is a Presenter? A domain level object that turns raw data into agent perception: a schema, rules, UI blocks, a truncation policy, next actions, and redaction paths. One per entity, shared across all tools returning that entity.
How does MVA reduce hallucination? By removing the situations in which hallucination is the cheapest move. When the next actions are explicit, the model does not invent tool names. When the schema is strict, hallucinated fields are rejected with the list of valid ones. When errors carry recovery hints, the model retries correctly instead of looping.
Does it work with my model? Yes, if the model can call tools. Claude, GPT, Gemini, and any MCP compatible client read the package the same way, because the perception layer lives on the server.
Why I built it
Each architecture shift I have lived through had the same trigger: the consumer changed, and the old architecture assumed a consumer that no longer existed. The browser did it to desktop. Mobile did it to the browser. The agent is doing it now, and the assumption that broke is the one we never wrote down: that the consumer would fill in the gaps.
MVA is the name for closing the gaps in the interface instead of hoping the model closes them. Write the boundary, write the rules, write the actions, and let the model do what it is actually good at.
