Atgal į žurnalą
11 min. skaitymo

MCP UI Kūrimas: Serverio Valdomi Interaktyvūs Valdikliai DI Agentams

Serverio valdomos generatyvinės vartotojo sąsajos architektūra per Model Context Protocol (MCP), naudojant struktūrizuotas JSON schemas, izoliuotą kliento atvaizdavimą ir dvikrypčius veiksmų ciklus.

MCPAI AgentsServer-Driven UIReactTypeScriptArchitecture
MCP UI Kūrimas: Serverio Valdomi Interaktyvūs Valdikliai DI Agentams

Model Context Protocol (MCP) išsprendė ilgai varginusią AI agentų problemą: suteikė švarų, universalų standartą modeliams sujungti su duomenų bazėmis, vietiniais komandinės eilutės įrankiais ir nuotolinėmis API per JSON-RPC 2.0. Daugiau jokių atskirų adapterių kiekvienam modelių tiekėjui.

Tačiau liko viena akivaizdi problema: standartiniai MCP įrankiai grąžina tik paprastą tekstą arba neapdorotas JSON eilutes.

Kai agentas atlieka užklausą įmonės žinių bazėje, tiria diegimo logus ar renka duomenis, paprasto teksto išmetimas pokalbio lange sukuria prastą vartotojo patirtį. Negalima vienu paspaudimu filtruoti įrašų, atverti detalių skydelio ar atlikti tolesnio veiksmo be naujos pilnos tekstinės užklausos.

MCP UI tai išsprendžia sujungdamas Server-Driven UI (SDUI) su MCP serveriais. Vietoj teksto srautų serveris grąžina tipizuotas komponentų schemas ir veiksmų paketus. Kliento pusė juos saugiai atvaizduoja kaip tikrus interaktyvius React valdiklius su tiesioginiu grįžtamuoju ryšiu į agento vykdymo aplinką.

Loading interactive sandbox...

Išnagrinėkime šią architektūrą išsamiai: nuo ryšio protokolo ir TypeScript serverių kūrimo iki izoliuotų React renderių ir saugumo ribų.


Architektūra: Trijų Sluoksnių MCP UI Įvykių Ciklas

Tradiciniame MCP modelyje kliento mazgas veikia tik kaip pasyvus teksto perdavimo kanalas tarp modelio ir MCP serverio.

Naudojant MCP UI, klientas perima dvi naujas atsakomybes: Komponentų Registrą (kuris susieja komponentų ID su patikrintais React komponentais) ir Veiksmų Dispečerį (kuris valdiklio paspaudimus nukreipia tiesiai į tolesnius įrankių iškvietimus be pakartotinio LLM apdorojimo).

TEXT
┌──────────────────────────────────────────────────────────────────┐
│                  AI Agent Client Host (Next.js)                  │
│                                                                  │
│  ┌────────────────────┐       ┌───────────────────────────────┐  │
│  │  LLM Orchestrator  │       │      Dynamic UI Renderer      │  │
│  │  (Vercel AI SDK)   │       │                               │  │
│  └─────────┬──────────┘       │  ┌─────────────────────────┐  │  │
│            │                  │  │     Component Cache     │  │  │
│            │ tools/call       │  └────────────┬────────────┘  │  │
│            ▼                  │               ▼               │  │
│  ┌────────────────────┐       │    [ BlogDiscoveryCard ]      │  │
│  │  MCP Client Layer  │──────►│    [   Action Buttons  ]      │  │
│  │ (JSON-RPC Router)  │◄──────│               │               │  │
│  └─────────┬──────────┘       └───────────────┼───────────────┘  │
│            │                                  │                  │
│            │ JSON-RPC 2.0 (Stdio / SSE)       │                  │
└────────────┼──────────────────────────────────┼──────────────────┘
             │                                  │
             ▼                                  │ UI Action Callback
┌────────────────────────────────────────┐      │ (tools/call: on_select)
│           Custom MCP Server            │      │
│                                        │      │
│  ┌──────────────────────────────────┐  │      │
│  │ Tool: discover_knowledge_node    │  │      │
│  │ ├─ Query Vector / Metadata Store │  │      │
│  │ └─ Generate UI Schema Definition │◄─┴──────┘
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

Gyvavimo ciklas susideda iš penkių deterministinių etapų:

  1. Užklausos Vykdymas: Agentas iškviečia MCP įrankį (discover_knowledge_node) su paieškos parametrais arba esybės identifikatoriais.
  2. Dvigubo Duomenų Paketo Serializavimas: MCP serveris sugeneruoja du paketus: tekstinę markdown atsarginę versiją terminalo klientams ir struktūrizuotą JSON schemą su tipu application/vnd.mcp.ui+json.
  3. Registro Sprendimas: Kliento mazgas gauna rezultatą, patvirtina schemos parašą su Zod ir suranda prašomą komponentą vietiniame registre.
  4. Izoliuotas Pajungimas: Mazgas prijungia komponentą, perduoda tipizuotas savybes (props) ir susieja atgalinio iškvietimo valdiklius.
  5. Dvikryptis Atgalinis Ryšys: Vartotojui spustelėjus veiksmo mygtuką (pvz., filtruoti pagal žymą arba išskleisti santrauką), komponentas išsiunčia veiksmą per MCP kliento sluoksnį ir atlieka tolesnį įrankio iškvietimą be pakartotinio tekstinio raginimo.

Wire Specification: The MCP UI Protocol Contract

Siekiant išlaikyti visišką suderinamumą su oficialia Model Context Protocol specifikacija, sąsajos duomenų paketai įtraukiami į standartinius MCP įrankių rezultatus kaip struktūrizuoti resursai.

The JSON-RPC 2.0 Wire Format

Kai MCP įrankis sukuria interaktyvų sąsajos komponentą, jis užpildo content masyvą tipizuota sąsajos apibrėžtimi:

JSON
{
  "jsonrpc": "2.0",
  "id": "req-9841",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Found 1 matching article: 'Building MCP UI: Server-Driven Interactive Widgets for AI Agents'."
      },
      {
        "type": "resource",
        "resource": {
          "uri": "ui://components/blog-discovery-card/mcp-ui-server-driven-interactive-components",
          "mimeType": "application/vnd.mcp.ui+json",
          "text": "{\"component\":\"BlogDiscoveryCard\",\"version\":\"1.0.0\",\"props\":{\"title\":\"Building MCP UI: Server-Driven Interactive Widgets for AI Agents\",\"slug\":\"mcp-ui-server-driven-interactive-components\",\"description\":\"Architecting server-driven generative UI over Model Context Protocol (MCP)...\",\"readingTime\":8,\"tags\":[\"MCP\",\"AI Agents\",\"Server-Driven UI\"],\"coverImage\":\"/images/blog/mcp-ui-server-driven-interactive-components-cover.png\"},\"actions\":[{\"id\":\"filter_tag\",\"label\":\"Filter by Tag\",\"tool\":\"discover_knowledge_node\",\"parameters\":{\"tag\":\"AI Agents\"}},{\"id\":\"preview_section\",\"label\":\"Read Abstract\",\"tool\":\"get_article_abstract\",\"parameters\":{\"slug\":\"mcp-ui-server-driven-interactive-components\"}}]}"
        }
      }
    ]
  }
}

TypeScript UI Schema Definition

Naudodami Zod, apibrėžiame griežtą struktūrinį kontraktą visiems serverio generuojamiems sąsajos komponentams:

TYPESCRIPT
import { z } from "zod";

export const McpUiActionSchema = z.object({
  id: z.string(),
  label: z.string(),
  tool: z.string(),
  parameters: z.record(z.unknown()),
  variant: z.enum(["primary", "secondary", "danger"]).default("secondary"),
});

export const McpUiPayloadSchema = z.object({
  component: z.string(),
  version: z.string(),
  props: z.record(z.unknown()),
  actions: z.array(McpUiActionSchema).default([]),
});

export type McpUiAction = z.infer<typeof McpUiActionSchema>;
export type McpUiPayload = z.infer<typeof McpUiPayloadSchema>;

Server-Side Implementation: Exposing MCP UI in TypeScript

Įdiekime gamybai paruoštą Node.js MCP serverį naudodami @modelcontextprotocol/sdk. Šis serveris atveria discover_knowledge_node įrankį, grąžinantį skaitomą tekstą ir struktūrizuotą BlogDiscoveryCard komponento duomenų paketą.

TYPESCRIPT
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

interface KnowledgeArticle {
  title: string;
  slug: string;
  description: string;
  readingTime: number;
  tags: string[];
  coverImage: string;
}

const KNOWLEDGE_BASE: Record<string, KnowledgeArticle> = {
  "mcp-ui-server-driven-interactive-components": {
    title: "Building MCP UI: Server-Driven Interactive Widgets for AI Agents",
    slug: "mcp-ui-server-driven-interactive-components",
    description: "Architecting server-driven generative UI over Model Context Protocol using JSON schemas and sandboxed rendering.",
    readingTime: 8,
    tags: ["MCP", "AI Agents", "Server-Driven UI", "React"],
    coverImage: "/images/blog/mcp-ui-server-driven-interactive-components-cover.png",
  },
};

const server = new Server(
  {
    name: "knowledge-mcp-ui-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

// Register Tool Discovery
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "discover_knowledge_node",
        description: "Search technical journal articles and return rich interactive cards.",
        inputSchema: {
          type: "object",
          properties: {
            topic: { type: "string", description: "Search query or keyword" },
            tag: { type: "string", description: "Optional category tag filter" },
          },
          required: ["topic"],
        },
      },
    ],
  };
});

// Handle Tool Execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name !== "discover_knowledge_node") {
    throw new Error(`Unknown tool: ${request.params.name}`);
  }

  const args = request.params.arguments as { topic: string; tag?: string };
  const matchedKey = Object.keys(KNOWLEDGE_BASE).find((key) =>
    key.includes(args.topic.toLowerCase().replace(/\s+/g, "-"))
  ) || "mcp-ui-server-driven-interactive-components";

  const article = KNOWLEDGE_BASE[matchedKey];

  // Construct UI Component Payload
  const uiPayload = {
    component: "BlogDiscoveryCard",
    version: "1.0.0",
    props: {
      title: article.title,
      slug: article.slug,
      description: article.description,
      readingTime: article.readingTime,
      tags: article.tags,
      coverImage: article.coverImage,
    },
    actions: [
      {
        id: "filter_by_tag",
        label: `More on ${article.tags[0]}`,
        tool: "discover_knowledge_node",
        parameters: { topic: article.tags[0], tag: article.tags[0] },
        variant: "secondary" as const,
      },
      {
        id: "open_reader",
        label: "Open Full Post",
        tool: "navigate_route",
        parameters: { href: `/en/journal/${article.slug}` },
        variant: "primary" as const,
      },
    ],
  };

  return {
    content: [
      {
        type: "text",
        text: `Found article: "${article.title}" (${article.readingTime} min read).`,
      },
      {
        type: "resource",
        resource: {
          uri: `ui://knowledge/${article.slug}`,
          mimeType: "application/vnd.mcp.ui+json",
          text: JSON.stringify(uiPayload),
        },
      },
    ],
  };
});

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch((err) => {
  process.stderr.write(`Server startup failed: ${err.message}\n`);
  process.exit(1);
});

Client-Side Host: Dynamic Component Registry & Action Bridge

Kliento pusėje (pavyzdžiui, Next.js App Router taikomojoje programoje) sukuriame atvaizdavimo mazgą, kuris:

  1. Išanalizuoja MCP įrankių atsakymus ir aptinka application/vnd.mcp.ui+json resursus.
  2. Sugretina komponento pavadinimą su tipizuotu React komponentų registru.
  3. Vartotojo atliktų sąveikų įvykius tiesiogiai perduoda atgal į MCP transportavimo kanalą.

The React Component Registry

TSX
import React from "react";
import Image from "next/image";
import { McpUiAction } from "./types";

interface BlogDiscoveryCardProps {
  title: string;
  slug: string;
  description: string;
  readingTime: number;
  tags: string[];
  coverImage: string;
  actions: McpUiAction[];
  onAction: (action: McpUiAction) => void;
}

export function BlogDiscoveryCard({
  title,
  description,
  readingTime,
  tags,
  coverImage,
  actions,
  onAction,
}: BlogDiscoveryCardProps) {
  return (
    <div style={{ border: "1px solid var(--border-color, #e2e8f0)", borderRadius: 8, padding: 16, background: "var(--surface, #ffffff)" }}>
      {coverImage && (
        <div style={{ position: "relative", width: "100%", height: 160, marginBottom: 12 }}>
          <Image
            src={coverImage}
            alt={title}
            fill
            sizes="(max-width: 768px) 100vw, 400px"
            style={{ objectFit: "cover", borderRadius: 4 }}
          />
        </div>
      )}
      <div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
        {tags.map((tag) => (
          <span key={tag} style={{ fontSize: "0.75rem", background: "#f1f5f9", padding: "2px 8px", borderRadius: 4 }}>
            {tag}
          </span>
        ))}
        <span style={{ fontSize: "0.75rem", color: "#64748b", marginLeft: "auto" }}>
          {readingTime} min read
        </span>
      </div>
      <h3 style={{ margin: "0 0 8px 0", fontSize: "1.1rem" }}>{title}</h3>
      <p style={{ margin: "0 0 16px 0", fontSize: "0.875rem", color: "#475569" }}>{description}</p>
      
      <div style={{ display: "flex", gap: 8 }}>
        {actions.map((act) => (
          <button
            key={act.id}
            onClick={() => onAction(act)}
            style={{
              padding: "6px 12px",
              borderRadius: 4,
              cursor: "pointer",
              fontSize: "0.875rem",
              background: act.variant === "primary" ? "#0f172a" : "#f8fafc",
              color: act.variant === "primary" ? "#ffffff" : "#0f172a",
              border: "1px solid #cbd5e1",
            }}
          >
            {act.label}
          </button>
        ))}
      </div>
    </div>
  );
}

// Registry map
export const COMPONENT_REGISTRY: Record<string, React.ComponentType<any>> = {
  BlogDiscoveryCard,
};

The Dynamic Host Renderer

TSX
"use client";

import React from "react";
import { McpUiPayloadSchema, McpUiPayload, McpUiAction } from "./types";
import { COMPONENT_REGISTRY } from "./registry";

interface McpToolResource {
  uri: string;
  mimeType: string;
  text: string;
}

interface McpToolResult {
  content: Array<
    | { type: "text"; text: string }
    | { type: "resource"; resource: McpToolResource }
  >;
}

interface McpUiHostProps {
  toolResult: McpToolResult;
  onDispatchTool: (toolName: string, params: Record<string, unknown>) => Promise<void>;
}

export function McpUiHost({ toolResult, onDispatchTool }: McpUiHostProps) {
  // Extract UI resources
  const uiResource = toolResult.content.find(
    (c) => c.type === "resource" && c.resource.mimeType === "application/vnd.mcp.ui+json"
  );

  if (!uiResource || uiResource.type !== "resource") {
    return null;
  }

  let parsedPayload: McpUiPayload;
  try {
    const rawJson = JSON.parse(uiResource.resource.text);
    parsedPayload = McpUiPayloadSchema.parse(rawJson);
  } catch {
    return (
      <div style={{ color: "#dc2626", fontSize: "0.875rem" }}>
        Failed to validate MCP UI component payload.
      </div>
    );
  }

  const TargetComponent = COMPONENT_REGISTRY[parsedPayload.component];
  if (!TargetComponent) {
    return (
      <div style={{ color: "#d97706", fontSize: "0.875rem" }}>
        Unknown component &apos;{parsedPayload.component}&apos; requested by MCP server.
      </div>
    );
  }

  const handleAction = async (action: McpUiAction) => {
    await onDispatchTool(action.tool, action.parameters);
  };

  return (
    <div className="mcp-ui-host-container" style={{ margin: "16px 0" }}>
      <TargetComponent
        {...parsedPayload.props}
        actions={parsedPayload.actions}
        onAction={handleAction}
      />
    </div>
  );
}

Security Boundaries & Sandboxing Untrusted Servers

Leidimas išoriniams ar nuotoliniams MCP serveriams grąžinti sąsajos apibrėžtis kelia potencialių saugumo grėsmių be apgalvotos gynybos. Mes taikome tris saugumo lygius:

TEXT
┌──────────────────────────────────────────────────────────────────────────┐
│                             Defense-in-Depth                             │
│                                                                          │
│  1. Strict Schema Whitelisting (No raw HTML / eval execution)            │
│  2. Shadow DOM CSS Isolation (Prevents CSS token override / injection)   │
│  3. Sandboxed Iframe Boundary for 3rd-Party Plugins                      │
│     (sandbox="allow-scripts", postMessage only)                          │
└──────────────────────────────────────────────────────────────────────────┘

1. Schema-Driven Whitelisting (Zero Eval)

Niekada neleiskite MCP serveriui grąžinti neapdorotų JSX eilučių, JavaScript funkcijų ar tiesioginių HTML žymų. Visi sąsajos rezultatai privalo būti griežtai deklaratyvios JSON schemos, patikrintos Zod. Kliento mazgas susieja leistinus identifikatorius (BlogDiscoveryCard, MetricGrid) tik su patikrintais vidiniais React komponentais.

2. Styling Containment via Shadow DOM

Norėdami apsaugoti sistemą nuo nepageidaujamų CSS taisyklių įterpimo (pvz., nematomų sluoksnių paspaudimų perėmimui), apgaubkite kliento atvaizdavimo mazgus į Shadow Root:

TSX
import React, { useRef, useEffect } from "react";
import ReactDOM from "react-dom/client";

export function ShadowDomBoundary({ children }: { children: React.ReactNode }) {
  const mountRef = useRef<HTMLDivElement>(null);
  const rootRef = useRef<ReactDOM.Root | null>(null);

  useEffect(() => {
    if (!mountRef.current) return;

    const shadow = mountRef.current.shadowRoot || mountRef.current.attachShadow({ mode: "open" });
    if (!rootRef.current) {
      rootRef.current = ReactDOM.createRoot(shadow);
    }
    rootRef.current.render(children);

    return () => {
      // Cleanup on unmount
    };
  }, [children]);

  return <div ref={mountRef} />;
}

3. Third-Party Plugin Isolation via Sandboxed Iframes

Palaikant trečiųjų šalių papildinius su nestandartiniais maketais, izoliuokite visą atvaizdavimo kontekstą <iframe> elemente:

HTML
<iframe
  sandbox="allow-scripts"
  srcdoc="<!DOCTYPE html><html><body><div id='root'></div></body></html>"
  title="MCP UI Isolated Container"
/>

Sąmoningai atsisakius allow-same-origin, įterptas rėmelis veikia nepermatomoje kilmėje ir negali pasiekti pagrindinės programos slapukų, sesijos žetonų, vietinės saugyklos ar tėvinio DOM.


Headless and Terminal Fallbacks

Kritinis operacinis reikalavimas MCP serveriams yra protokolo atsparumas: serveris privalo išlikti visiškai veikiantis, kai yra iškviečiamas iš komandinės eilutės, automatinių CI sistemų ar kūrimo aplinkų be grafinio atvaizdavimo.

Siekiant palaikyti abi aplinkas, kiekvienas įrankis suformuoja dvigubą atsakymą:

TYPESCRIPT
import { McpUiPayload } from "./types";

interface KnowledgeArticle {
  title: string;
  slug: string;
  description: string;
  readingTime: number;
  tags: string[];
}

export function buildDualMcpResponse(article: KnowledgeArticle, uiPayload: McpUiPayload) {
  return {
    content: [
      // 1. Terminal / Markdown Fallback (Read by CLI agents)
      {
        type: "text",
        text: [
          `### ${article.title}`,
          `*${article.description}*`,
          `- Reading Time: ${article.readingTime} min`,
          `- Tags: ${article.tags.join(", ")}`,
          `- URL: https://damandeep.dev/en/journal/${article.slug}`,
        ].join("\n"),
      },
      // 2. Rich UI Resource (Rendered by GUI hosts)
      {
        type: "resource",
        resource: {
          uri: `ui://knowledge/${article.slug}`,
          mimeType: "application/vnd.mcp.ui+json",
          text: JSON.stringify(uiPayload),
        },
      },
    ],
  };
}

Jei klientas nepalaiko application/vnd.mcp.ui+json, jis apdoroja tik text lauką ir pateikia vartotojui aiškią markdown informaciją be klaidų.


Architektūriniai Kompromisai: Kada Verta Naudoti MCP UI (ir Kada Ne)

Kaip ir bet kuris kitas architektūrinis šablonas, Server-Driven UI reikalauja inžinerinių kompromisų:

Inžinerinė Dimensija Tik Tekstiniai MCP Įrankiai MCP UI (Serverio Valdoma Sąsaja)
Sąveikos Vėlavimas Didelis (Reikalauja pilno LLM ciklo kiekvienam paspaudimui ar filtravimui) Akimirksnis (< 50ms tiesioginis įvykio išsiuntimas į įrankį)
Kliento Sąsajos Sudėtingumas Mažas (Standartinis teksto atvaizdavimas) Vidutinis (Reikalinga registro priežiūra, schemų tikrinimas ir veiksmų nukreipimas)
Klaidų Tikimybė Minimali (Teksto generavimo netikslumai) Reikalauja schemų versijavimo ir atsarginių kortelių nežinomiems komponentams
Saugumo Paviršius Standartinės užklausų injekcijos rizikos Reikalauja griežtų deklaratyvių schemų, CSS izoliavimo ir iframe saugyklų

Išvados Praktikai

Jei jūsų agento darbo eiga yra grynai tekstinė arba veikia fone be vartotojo įsikišimo, paprastų tekstinių įrankių visiškai pakanka. Tačiau jei kuriate programinę įrangą, kurioje žmonės priima sprendimus (patvirtina pakeitimus, tiria incidentus ar filtruoja duomenis), MCP UI paverčia gremėzdišką susirašinėjimą sklandžia, pilnaverte interaktyvia patirtimi.

Share this article