Site
全記事

公開日 2026年9月25日約13分で読了

MVA、Model View Agentパターン:AIエージェント時代のバックエンドアーキテクチャ

MVCは、インターフェースの消費者が経験から文脈を推論できることを前提としていました。エージェントにはそれができません。MVAはViewをPresenterに置き換え、エージェントがデータを読む瞬間にルール、ガードレール、アフォーダンスを付与する知覚レイヤを提供します。パターン、コード、トークン計算、そして費用を回収する五つのユースケース。

Renato Marinho

著者 Renato Marinho

Founder · Vinkius

The MVA pattern: a raw database row crosses the Model boundary where undeclared fields are dropped, the Presenter wraps the surviving fields into a six block perception package of data, ui, embeds, hints, rules and actions, and the agent reads that package and picks the next tool from the offered affordances

MVAはModel, View, Agentの頭文字です。AIエージェントが対話するバックエンドのためのアーキテクチャパターンであり、あなたのインターフェースの消費者が種を変えたからこそ存在します。

五十年間、私たちは推論できる消費者向けにインターフェースを設計してきました。人間は amount_cents: 45000 を読めば、それが四百五十ドルだと分かります。請求書を以前に見たことがあるからです。エージェントは同じフィールドを読んで、推測しなければなりません。四万五千ドルと推測することもあります。存在しない決済ツールを発明することもあります。ワークフローがあることを誰も教えなかったので、必須のワークフローを飛ばすこともあります。これらはどれもプロンプトの問題ではありません。すべてアーキテクチャの問題です。

私はMCP Fusion、本番向けMCPサーバのTypeScriptフレームワークを構築する中でこのパターンに名前を与え、実装しました。そこではMVAがツールを構築するデフォルトの方法です。本記事は、シニアエンジニアから「MVAとは何か、なぜ名前を変えただけのMVCではないのか、どこで本当にお金に見合うのか」と聞かれたときに私がする説明です。

MVAをひと段落で

MVAは、MVCの人間向けのViewをPresenterに置き換えます。ドメインデータと大規模言語モデルの間に位置する知覚レイヤで、エージェントが何を見るか、どう解釈すべきか、次に何を許されているかを決めます。Modelは引き続きドメインデータを定義し、検証します。Presenterはそのデータをスキーマ境界、解釈ルール、サーバ側でレンダリングされた視覚ブロック、明示された次のアクションで包みます。Agent、つまり任意のLLMは、生のJSONではなく構造化された知覚パッケージを受け取ります。推測をやめ、データと共に届く指示に従うようになります。

レイヤ正体責務
ModeldefineModel()ドメインデータ: フィールドの型、デフォルト、エージェントに隠すフィールド、一括代入から保護するフィールド
ViewPresenter知覚: スキーマ境界、ルール、UIブロック、切り詰め上限、次のアクション、PIIマスキング
Agent任意のLLMパッケージを消費し、推論し、提示された一覧から次のツールを呼ぶ

なぜMVCが機能しなくなるのか

MVCは、視覚UIをレンダリングし、空間認識を持ち、可視の選択肢から選び、次の手順をハルシネーションしない消費者を前提としていました。エージェントにはその性質が一つもなく、生のAPIにLLMを向ける瞬間に四つの構造的故障モードが現れます。

コンテキストの飢餓。 データが解釈ルールなしに届きます。{ amount_cents: 45000 } にはルールが付いていないので、モデルはフィールド名に頼ります。しかしフィールド名の命名規則は信頼できるシグナルではありません。私は同じフィールドが、三つの異なる会話でドル、ユーロ、「処理中」と描画されるのを見たことがあります。

アクションの盲目。 データを読んだ後、エージェントは次に何をすべきか決めなければなりません。アフォーダンスがないとツール名をでっち上げ、ツールが billing.pay なのに billing.process_payment を呼び、アクションの存在を知らなかったためにそのまま止まります。

知覚のドリフト。 同じ請求書が二つのツールで異なる形で戻ります。一方は amount がドルで state: "open"、もう一方は amount_cents と status: "pending" です。モデルはこれを一つのエンティティとして整合させられず、あなたのプロダクト内で一貫性のない振る舞いを始めます。

セキュリティの漏洩。 生のJSONはクエリが選んだすべての列を運びます: internal_margin、tenant_id、password_hash。そのすべてがコンテキストウィンドウに入り、ユーザが読む回答に現れる可能性があります。

一つ一つがアーキテクチャの欠陥です。プロンプトエンジニアリングで、存在しない出力境界を直すことはできません。境界が置かれるべき場所はプロンプトではないからです。

コードで見るMVA: 三つのレイヤ

Modelはドメインエンティティを一度だけ宣言します。ここで宣言しないフィールドはエージェントに届きません。

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'],
  });
});

PresenterがViewです。ドメインレベルで定義され、ツールごとではないので、一つの InvoicePresenter が請求書を返すすべてのツールを支えます。

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));

ツールがAgentの表面です。ハンドラは生のデータを返し、.returns() が知覚レイヤを取り付けます。

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') はモデルの fillable プロファイルから入力パラメータを導出するので、入力スキーマと出力スキーマが乖離できません。一つの宣言、二つの境界、どちらもコンパイル時に強制されます。

エージェントが実際に受け取るもの

ハンドラが戻ると、パイプラインは構造化知覚パッケージを組み立てます。最大六つのコンテンツブロックが、固定の順序で並びます。

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

順序は飾りではありません。言語モデルはコンテキストの終わりをより重く扱うので、解釈ルールと利用可能なアクションが最後に届き、まさにモデルが何を呼ぶか決める直前に置かれます。データが先に来るのは、その後に続く解釈の根拠になるからです。

生のMCPサーバが返すものとの対比が、画面一枚に収まったこの議論のすべてです:

// 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

MVAがお金に見合う五つのユースケース

1. 請求とフィンテック。 上の請求書の例は飾りではありません。お金は、誤った推渵がそのままサポートチケットやコンプライアンスインシデントになるドメインです。セントのルール、ロールによるマスキングのルール、決済のアフォーダンスは一つのPresenter上の三行で、請求ツールがすべてそれを継承します。ジュニア開発者は、百で割るルールを忘れた請求ツールを出荷できません。ルールは彼のツールの中にないからです。

2. 大規模な運用。 上限のないテーブルに対する tasks.list は三千行を返し、一行約五百トークンで約百五十万トークンになります。それを収められるコンテキストウィンドウは存在しません。.agentLimit(50, ...) は配列を切り詰め、実際のフィルタパラメータを名指しする教育ブロックを付加します: status、assignee、sprint_id、due_before。モデルの次の呼び出しはフィルタされたクエリになります。切り詰めるだけなら、永遠に同じ切り詰められたページを読むでしょう。切り詰めと教育の組み合わせが自己修正ループを生みます。

3. 規制対象の個人データ。 ヘルスケア、人事、PIIを含むあらゆるCRMは、「これを表示しないでください」より強い境界を必要とします。redactPII は patients[*].diagnosis のようなオブジェクトパスをマスキング関数にコンパイルするので、マスクされた値がモデルに届く一方で、UIブロックとルールは本来のデータを見続けます。m.hidden() と組み合わせれば、データベースから絶対に出してはいけないフィールドは、一度も宣言されなかったフィールドと同じになります。

4. マルチテナントSaaS。 Presenterのルールはリクエストコンテキストを受け取るので、同じ請求書が管理者と閲覧ロールで異なる風に知覚され、日付はテナントのロケールで整形されます。第二のコードパスは不要です。これは、チームが手動で維持し、たいてい間違える、テナント別のプロンプト分岐という種類のものを置き換えます。

5. 承認と注文のワークフロー。 アフォーダンスは静的な一覧ではなく現在の状態から計算されます。保留中の注文は orders.approve を提示し、出荷済みの注文は提示しません。これはツールのためのHATEOASです。モデルに対し、今いる状態から合法な遷移だけを教えます。これがハルシネーションされたツール呼び出しの最も一般的な種類を消し去ります。

六つ目のケースを挙げる価値があります。なぜなら、ほとんどのチームがそこにいるからです。すでにReactのダッシュボードと、REST APIと、データベースを持っています。MVAはそれらを置き換えろとは言いません。人間にはMVCを残し、エージェント向けの表面を追加し、両者が同じドメインモデルを消費します。この二重インターフェースパターンこそが「AIネイティブ」な仕事の大部分が実際に着地する場所であり、名前を付ければ移行は書き直しではなく、境界のあるプロジェクトになります。

トークンの計算

MVAがもう一つ効くのは請求書です。知覚レイヤの欠落に対する従来の修正は、ドメインルールをグローバルなシステムプロンプトに詰め込むことです。ドメインがアクティブかどうかにかかわらず、毎呼び出しごとに送信されます。十五のドメインエンティティを持つプロダクトは、約二千トークンのルールを毎リクエスト乗せ、その八十七パーセントはどのターンでも無関係です。

コンテキストツリーシェイキング: グローバルなシステムプロンプトは十ターンの各々に約二千トークンのルールを送り、合計約二万、そのうち八十七パーセントは無関係。Presenterに付いたルールはアクティブなドメインだけを送り、1ターン約二百トークン、合計二千、会話あたり約一万八千トークンの節約

Context tree shakingがその代替の名前です: ルールはPresenterに付けられ、そのドメインがアクティブなときだけ現れます。十ターンの会話は、ルールで約二万トークンから約二千トークンに下がり、届くルールは正しいものになります。コストと同じくらい重要な副作用があります。請求ルールがタスクの会話に存在しなければ、モデルは百で割るルールをタスク数に誤適用できません。私はこの正確なエラーを本番で見たことがあり、ルールが最初からコンテキストに無ければ、それは消えます。

MVA対MVC、REST、GraphQL、RPC

消費者出力次のアクション境界
MVC人間のブラウザページごとのHTMLリンクとボタンビューテンプレート
RESTクライアントコード生のJSONHATEOASのリンクはURLなし
GraphQLクライアントコードクライアントが選んだフィールドなしクエリレベルのみ
RPCクライアントコード生の型付きデータなし入力のみ
MVAAIエージェント知覚パッケージ状態からのツール名入力と出力

HATEOAS付きRESTは最も近い先祖であり、最も明確な区別に値します。HATEOASはレスポンスにリンクを埋め込みます。それは正しい直感ですが、リンクはHTTPエンドポイントへのURLであり、エージェントは名前でツールを呼びます。MVAのアフォーダンスは、現在のデータ状態から計算された意味的な理由と共にツール名を返し、RESTには運びようがないルールや視覚ブロックを伴います。GraphQLはどのフィールドを取得するかを解決しますが、それはかつてボトルネックではありませんでした。ボトルネックは、取得した後にフィールドをどう解釈するかです。

MVAがではないもの

MVCの置き換えではありません。消費者がブラウザを持つ人間なら、MVCもMVVMも正しく、MVAのどの部分もダッシュボードを良くしません。エージェントフレームワークでもありません: 計画せず、メモリを管理せず、モデルを選びません。あなたのデータと、それを読むモデルとの間の契約であり、その契約を明示的で、検証され、テスト可能なものにします。

すべてか無かでもありません。今日の午後に、レガシーサーバの一つのツールにPresenterを付け、残り五十はそのままにしておけます。

はじめ方

npm install @mcpfusion/core @modelcontextprotocol/sdk
npx mcpfusion create

プロジェクトをひとつ作り、modelを定義し、Presenterを定義し、一つのqueryに .returns() を付けます。そのパターンが何かしているかを最も早く確かめるには、ツールの結果を前後で読むことです。生のJSON、そして六ブロックのパッケージ。その差が議論そのものです。

この方法で作ったコネクタの完全な手順が欲しければ、MVAの分割、Presenter、自己修復エラーがソースコード付きで 自前のMCPコネクタを組む に説明されています。ランタイム側、つまりエージェントのトラフィックがスケールでどう安全に保たれるかが知りたければ、AIエージェントの本番運用を安全に行う方法 にあります。

よくある質問

MVAはMCPだけのものですか? パターンはプロトコル非依存で、MCP Fusionがその参照実装です。LLMをツール呼び出しの一方の端に置くものなら何でも、知覚レイヤの恩恵を受けます。MCPは単に、境界が最も可視化される場所です。MCPサーバはデフォルトで生のJSONを届けるからです。

MVAはMVCに取って代わりますか? いいえ。二重インターフェースパターンが正直な枠組みです。人間のダッシュボードにはMVCを残し、エージェント表面にはMVAを追加し、ドメインモデルを両者で共有します。

Presenterとは何ですか? 生のデータをエージェントの知覚に変える、ドメインレベルのオブジェクトです。スキーマ、ルール、UIブロック、切り詰めの方針、次のアクション、マスキングのパスを持ちます。エンティティごとに一つ、そのエンティティを返すすべてのツールで共有されます。

MVAはどうハルシネーションを減らしますか? ハルシネーションが最も安い手である状況を取り除くことで。次のアクションが明示的なら、モデルはツール名をでっち上げません。スキーマが厳格なら、ハルシネーションされたフィールドは有効なフィールドの一覧と共に拒否されます。エラーが復旧のヒントを含めば、モデルはループする代わりに正しくリトライします。

私のモデルで動きますか? はい、そのモデルがツールを呼べれば。Claude、GPT、Gemini、その他のMCP互換クライアントは皆、同じようにパッケージを読みます。知覚レイヤはサーバに住んでいるからです。

なぜ私がこれを作ったか

私が生きてきたアーキテクチャの転換はすべて、同じ引き金を持っていました。消費者が変わり、古いアーキテクチャがもはや存在しない消費者を前提にしていた、ということです。ブラウザはデスクトップにそれをし、モバイルはブラウザにそれをし、今エージェントがそれをしています。そして壊れた前提は、私たちが一度も書き留めなかったものです。消費者が隙間を埋めてくれるだろう、と。

MVAとは、モデルが埋めてくれることを期待する代わりに、インターフェースの隙間を自ら閉じることにつけた名前です。境界を書き、ルールを書き、アクションを書く。そしてモデルに、本当に得意なことをさせる。

トピックmvaarchitectureagentsmcpmcpfusion