Skip to main content

Architecting the Generative Canvas: A Guide to Implementing MCP UI in Angular

Linear chat to "Canvas" architectures.

Architecting the Generative Canvas: A Guide to Implementing MCP UI in Angular

1. Introduction: The Paradigm Shift in Agentic Interfaces

The evolution of human-computer interaction (HCI) is currently witnessing a transition as significant as the move from command-line interfaces to graphical user interfaces (GUIs). This shift is driven by the rise of Agentic AI—autonomous systems capable not just of conversing, but of acting, creating, and manipulating digital environments. For the past two years, the dominant modality for this interaction has been the linear chat stream. While effective for simple query-response patterns, the “chat bubble” has proven woefully inadequate for complex workflows. When an engineer asks an AI to “refactor this microservice” or a designer requests “a new dashboard layout,” a text-based response results in what interface researchers call the “Text Wall” problem—a dense, unformatted block of information that forces the user to perform mental translation and manual extraction. 

The industry’s answer to this usability crisis is the “Canvas” architecture, famously exemplified by interfaces like Anthropic’s Claude Artifacts. In this paradigm, the user interface bifurcates: conversational intent and refinement occur in a chat pane, while the generative output—the code, the vector graphic, the React component—is rendered in a dedicated, interactive “Canvas” pane. This separation of concerns transforms the AI from a chatbot into a collaborative workspace. 

Underpinning this visual revolution is the Model Context Protocol (MCP). Originally conceived to standardize the backend connection between Large Language Models (LLMs) and external data sources (like databases, file systems, or APIs) , MCP has recently expanded its scope to the frontend through MCP-UI. This experimental extension allows MCP servers to dictate not just the data they return, but the interface used to present it. Instead of the client guessing how to render a SQL result set, the MCP server sends a UIResource defining a table, a chart, or an interactive form. 

However, for the enterprise software architect, a significant friction point disrupts this promising horizon. The current MCP ecosystem exhibits a profound “React Hegemony.” The official SDKs, the reference implementations, and the dominant tooling (such as the Vercel AI SDK) are overwhelmingly optimized for the React ecosystem, specifically Next.js. Documentation frequently assumes the presence of React hooks (useChat, use-mcp) and Virtual DOM primitives, leaving developers of Angular applications—a framework powering a vast plurality of enterprise-grade platforms—navigating a landscape of undocumented integration challenges.

This report, the fifth in our advanced series on Agentic Architecture, serves as the definitive technical manual for bridging this divide. We will deconstruct the architectural requirements of an MCP-UI driven Agent, analyze the specific impediments posed by the React-centric ecosystem, and provide a comprehensive, code-rich guide to implementing a fully functional, multi-modal Agent Canvas in Angular 18+. We will demonstrate that by leveraging modern Angular primitives—specifically Signals, Stand-alone Components, and Web Components—architects can not only achieve parity with React implementations but arguably surpass them in performance and strict modularity.

2. The Theoretical Foundation: Model Context Protocol and the User Interface

To implement MCP-UI effectively in Angular, one must first possess a rigorous understanding of the underlying protocol and how it redefines the client-server relationship in the context of Generative AI. The Model Context Protocol is not merely an API specification; it is a contract for context exchange.

2.1 The MCP Architecture: Host, Client, and Server

The MCP topology consists of three primary entities, each playing a distinct role in the data flow: 

  • The MCP Host: This is the user-facing application. In our context, this is the Angular application. It is responsible for the lifecycle of the user interaction, the rendering of the UI, and the orchestration of the agent’s “thought process.”

  • The MCP Client: A protocol connector that resides within the Host. It manages the connection (transport layer) to the various servers.

  • The MCP Server: An autonomous service that provides “Context” to the LLM. This context can be passive (resources like files or logs) or active (tools like “execute_sql” or “generate_image”).

In a traditional web architecture, the frontend (Angular) queries a backend API for JSON data and uses internal logic (Typescript component code) to determine how that data is rendered. MCP inverts this. The MCP Server, which might be a Python script running locally or a Node.js process in the cloud, provides the definition of the interface. The Host’s responsibility shifts from designing the UI to rendering the resource provided by the Server.

2.2 The structure of a UIResource

The core primitive of the MCP-UI extension is the UIResource. When an agent generates an artifact, it does not merely stream raw text; it emits a structured payload intended for visual consumption. Understanding the TypeScript interface of this resource is crucial for building our Angular renderer: 

interface[span_7](start_span)[span_7](end_span)[span_9](start_span)[span_9](end_span) UIResource { 
  type: 'resource'; 
  resource: { 
    uri: string;       // Unique identifier, e.g., 'ui://component/weather-widget' 
    mimeType: string;  // The instruction for how to render the payload 
    text?: string;     // The content payload (for text-based resources) 
    blob?: string;     // The content payload (for binary resources, base64 encoded) 
  }; 
}

The mimeType property is the polymorphic discriminator that drives the logic of our Angular “Canvas.” The specification currently standardizes three primary types, each requiring a distinct rendering strategy: 

MimeType Description Angular Rendering Strategy text/html A string containing a complete or partial HTML document. Used for self-contained widgets, generated mini-apps, or code previews. Sandboxed Iframe: Must use DomSanitizer and srcdoc to isolate the agent’s potentially unsafe script execution from the main application context. text/uri-list A URL pointing to an external resource (e.g., a hosted web application, a video stream, or a live dashboard). Managed Iframe: Requires bypassSecurityTrustResourceUrl to allow the Angular router or standard iframe to load cross-origin content. application/vnd.mcp-ui.remote-dom A JSON representation of a DOM tree. Allows the server to manipulate the client’s DOM directly without sending HTML strings. Web Component Wrapper: This type is heavily optimized for React’s Virtual DOM. In Angular, attempting to reconcile this natively is inefficient; the optimal path is wrapping the React renderer.

2.3 The “React Problem”: A Forensic Analysis

The user’s query highlights a critical frustration: “MCP-UI does support only React.” It is vital to deconstruct what this statement means technically. The protocol (JSON over HTTP/Stdio) is language-agnostic. However, the tooling layer has deep React coupling.

  • The Reference Implementation: The @mcp-ui/client library, maintained by the protocol creators, exports a <UIResourceRenderer /> component. This is a React Functional Component. It relies on useEffect for lifecycle management and React Context for passing protocol state. 

  • The Virtual DOM Assumption: The remote-dom resource type assumes a reconciliation algorithm compatible with React’s fiber architecture. Implementing a parser that converts this JSON tree into Angular’s IVy rendering instructions is theoretically possible but practically redundant given the complexity.

  • The Vercel Effect: The Vercel AI SDK, which simplifies connecting to LLMs, treated Angular as a second-class citizen until late 2024. While Angular support has landed , the documentation and examples for “Generative UI” (using useChat hooks to render component streams) remain React-exclusive. 

For an Angular architect, “solving” this problem does not mean rewriting the entire ecosystem. It means designing an Integration Layer—a set of adapters that allow Angular to consume React-centric libraries where necessary (via Web Components) while leveraging Angular’s superior state management (Signals) for the orchestration of the data stream. 

3. Architecting the Angular Agent Stack

To build a “Canvas” capable of rendering code, images, and video in real-time, the traditional Angular architecture (Module-based, Zone.js dependent, HttpClient driven) is insufficient. We must adopt a “Modern Angular” stack that aligns with the streaming nature of Generative AI.

3.1 The Streaming Data Layer: Handling NDJSON

Standard REST APIs operate on a Request-Response cycle: the client sends a request, waits, and receives a complete JSON object. Generative AI operates on Streaming. The LLM produces tokens one by one. Waiting for the full generation (which might take 60 seconds for a complex code artifact) destroys the user experience.

The standard format for this stream is NDJSON (Newline Delimited JSON). The server opens a connection and flushes a chunk of data ending in a newline \n for every update.

  • Chunk 1: {“type”: “text_delta”, “content”: “Here is”} 

  • Chunk 2: {“type”: “text_delta”, “content”: ” the code”} 

  • Chunk 3: {“type”: “tool_call”, “name”: “generate_ui”, “args”: {…}}

Angular Challenge: The standard HttpClient in Angular buffers responses by default. While reportProgress: true allows observing events, it is cumbersome for text streams. Angular Solution: We must utilize the Fetch API directly within an Angular Service, or utilize the Vercel AI SDK’s streamText utility, which wraps fetch and provides a ReadableStream compatible with modern JavaScript iterators.

3.2 State Management: The Case for Signals

In a high-frequency streaming scenario (50+ updates per second), Angular’s traditional Change Detection mechanism (Zone.js) can become a bottleneck. If every token triggers a full tree traversal to check for changes, the UI will jitter, especially on lower-powered devices.

Angular Signals (introduced in v16, matured in v17/18) provide the solution. A Signal is a reactive primitive that notifies only its direct consumers when it changes. By binding the incoming text stream to a WritableSignal, and deriving the artifact state via a computed signal, we ensure that only the text node in the Chat Window or the specific code block in the Canvas Window updates, without triggering a global digest cycle.

3.3 Security Architecture: The “Browser-within-a-Browser”

When we build an Agent that produces code or UI, we are effectively feature-flagging Cross-Site Scripting (XSS). If the Agent generates a

  • document.cookie (Session tokens) 

  • localStorage (JWTs, User preferences) 

  • indexedDB (Offline data)

Therefore, the Canvas must use strict sandboxing. Relying solely on DomSanitizer.bypassSecurityTrustHtml is dangerous if rendered directly in the main DOM. The architecture requires Iframe Isolation. By rendering the artifact in an iframe with a sandbox attribute (e.g., sandbox=“allow-scripts” but omitting allow-same-origin), we create a security boundary. The generated code runs, but it cannot access the parent Angular application’s storage or cookies.

4. Implementation Guide: The Angular Canvas

We will now walk through the code implementation of the Agent Canvas. This solution assumes a modern Angular setup (Version 18+), using Standalone Components and Signals.

4.1 Step 1: The Layout (Angular Split)

The foundational requirement of a “Canvas” interface is the ability to view chat and artifacts side-by-side. We utilize angular-split, a library that supports modern Angular and provides the necessary drag-and-drop resizing capabilities.

Project Setup: 

ng new agent-canvas --standalone 
npm install angular-split @ai-sdk/openai ai prismjs
The Main Component (app.component.ts): This component acts as the orchestrator. It holds the state of the conversation and the currently active artifact. 

import { Component, signal, effect } from '@angular/core'; 
import { CommonModule } from '@angular/common'; 
import { AngularSplitModule } from 'angular-split'; 
import { ChatWindowComponent } from './chat/chat-window.component'; 
import { CanvasWindowComponent } from './canvas/canvas-window.component'; 
import { UIResource } from './models/mcp-types'; 
 
@Component({ 
  selector: 'app-root', 
  standalone: true, 
  imports:, 
  template: ` 
     
       
         
         
           
           
         
         
         
           
           
         
 
       
     
  `, 
  styles: &#91;` 
   .layout-wrapper { height: 100vh; width: 100vw; display: flex; flex-direction: column; } 
    as-split-area { overflow: hidden; position: relative; }  
  `] 
}) 
export class AppComponent { 
  // Signal to hold the current UI Resource. 
  // Using a Signal ensures reactive updates to the Canvas without deep checking. 
  currentArtifact = signal(null); 
   
  // Computed signal to determine visibility 
  hasArtifact = computed(() => this.currentArtifact()!== null); 
 
  onArtifactGenerated(resource: UIResource) { 
    console.log('Orchestrator received artifact:', resource); 
    this.currentArtifact.set(resource); 
  } 
 
  closeCanvas() { 
    this.currentArtifact.set(null); 
  } 
}

4.2 Step 2: The Data Layer (Service with Vercel AI SDK)

Here we solve the “Streaming Data” challenge. We implement an Angular Service that wraps the Vercel AI SDK. This service is responsible for converting the incoming stream into a Signal that the UI can consume.

// src/app/services/agent.service.ts 
import { Injectable, signal } from '@angular/core'; 
import { createOpenAI } from '@ai-sdk/openai'; 
import { streamText, CoreMessage } from 'ai'; 
 
@Injectable({ providedIn: 'root' }) 
export class AgentService { 
  // The source of truth for the conversation history 
  messages = signal(); 
  isLoading = signal(false); 
 
  // Configure the provider (in production, proxy this through your backend to hide API keys) 
  private openai = createOpenAI({ apiKey: 'YOUR_OPENAI_KEY', dangerouslyAllowBrowser: true }); 
 
  async sendMessage(content: string) { 
    this.isLoading.set(true); 
 
    // 1. Optimistic Update: Add user message immediately 
    const userMsg: CoreMessage = { role: 'user', content }; 
    this.messages.update(msgs => &#91;...msgs, userMsg]); 
 
    try { 
      // 2. Initiate Stream 
      const result = await streamText({ 
        model: this.openai('gpt-4-turbo'), 
        messages: this.messages(), 
      }); 
 
      // 3. Prepare placeholder for Assistant response 
      let assistantContent = ''; 
      const assistantMsg: CoreMessage = { role: 'assistant', content: '' }; 
       
      // Update the signal to show the empty assistant message (loading state) 
      this.messages.update(msgs => &#91;...msgs, assistantMsg]); 
 
      // 4. Consume the ReadableStream using Async Iterator 
      for await (const textPart of result.textStream) { 
        assistantContent += textPart; 
         
        // Fine-grained update: We update the specific message content 
        // In a real app, you might use a separate signal for the "streaming" message 
        // to avoid array copying, but for simplicity: 
        this.messages.update(msgs => { 
          const newMsgs = &#91;...msgs]; 
          const lastMsg = newMsgs&#91;newMsgs.length - 1]; 
          if (lastMsg.role === 'assistant') { 
             lastMsg.content = assistantContent; 
          } 
          return newMsgs; 
        }); 
      } 
 
    } catch (error) { 
      console.error('Stream failed', error); 
      // Handle error state in UI 
    } finally { 
      this.isLoading.set(false); 
    } 
  } 
}

**4.3 Step 3: Integrating MCP-UI (Bridging the React Gap) **

This is the crux of the user’s specific difficulty. We need to render the UIResource.

The Problem: The remote-dom renderer is a React component. The Solution: We utilize Web Components as the interoperability standard. The @mcp-ui/client library provides a web component build .

However, using React-based Web Components in Angular has a specific quirk: React often fails to read complex objects passed as properties, expecting them as serialized JSON strings in attributes instead.

// src/app/canvas/canvas-window.component.ts 
import { Component, Input, computed, CUSTOM_ELEMENTS_SCHEMA, ElementRef, ViewChild, effect } from '@angular/core'; 
import { CommonModule } from '@angular/common'; 
import { UIResource } from '../models/mcp-types'; 
 
// Importing the Web Component registration script 
// This registers  in the browser's CustomElementRegistry 
import '@mcp-ui/client/dist/ui-resource-renderer.wc.js';  
 
@Component({ 
  selector: 'app-canvas-window', 
  standalone: true, 
  imports: &#91;CommonModule], 
  // CRITICAL: This allows Angular to tolerate the non-standard  tag 
  schemas:,  
  template: ` 
     
       
         {{ resource?.resource?.mimeType }} 
         Close 
       
 
       
         
         
       
     
  `, 
  styles: &#91;` 
   .canvas-host { height: 100%; display: flex; flex-direction: column; background: #fff; } 
   .render-area { flex: 1; overflow: auto; position: relative; } 
    ui-resource-renderer { display: block; height: 100%; width: 100%; } 
  `] 
}) 
export class CanvasWindowComponent { 
  @Input() resource: UIResource | null = null; 
  @ViewChild('renderer') rendererRef!: ElementRef; 
 
  // Signal derivation for the attribute binding 
  serializedResource = computed(() => { 
    return this.resource? JSON.stringify(this.resource) : null; 
  }); 
 
  constructor() { 
    // Handling React Synthetic Events that don't bubble nicely to Angular 
    effect(() => { 
      const el = this.rendererRef?.nativeElement; 
      if (el) { 
        // 'onUIAction' is the custom event dispatched by the MCP library 
        el.addEventListener('onUIAction', (e: CustomEvent) => { 
           console.log('Angular received MCP Action:', e.detail); 
           // Handle the action (e.g., trigger a new Agent message) 
        }); 
      } 
    }); 
  } 
 
  emitClose() { 
    // standard output emission 
  } 
}

Architectural Alternative (The React Wrapper): If the Web Component proves unstable (e.g., due to complex remote-dom diffing issues), the alternative is to create a “Bridge Component.” This component does not use the Web Component; instead, it imports react, react-dom, and the UIResourceRenderer React component, and manually mounts the React root onto an Angular ElementRef.

// bridge/react-bridge.component.ts 
import { Component, ElementRef, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; 
import * as React from 'react'; 
import * as ReactDOM from 'react-dom/client'; 
import { UIResourceRenderer } from '@mcp-ui/client'; 
 
@Component({ 
  selector: 'app-react-bridge', 
  template: '', 
  standalone: true 
}) 
export class ReactBridgeComponent implements OnChanges, OnDestroy { 
  @Input() resource: any; 
  private root: ReactDOM.Root | null = null; 
 
  constructor(private host: ElementRef) {} 
 
  ngOnChanges(changes: SimpleChanges): void { 
    if (!this.resource) return; 
 
    // Lazy initialization of React Root 
    if (!this.root) { 
      this.root = ReactDOM.createRoot(this.host.nativeElement.querySelector('.react-host')); 
    } 
 
    // Manual React Rendering 
    this.root.render( 
      React.createElement(UIResourceRenderer, { 
        resource: this.resource, 
        onUIAction: (action: any) => console.log('Action bridged:', action) 
      }) 
    ); 
  } 
 
  ngOnDestroy(): void { 
    this.root?.unmount(); 
  } 
}

Note on Trade-offs: This approach increases bundle size significantly (bundling React + Angular) but guarantees 100% fidelity with the React-based MCP library. 

5. Building Specific Agent Types: Code, Images, Video

When an agent produces code (e.g., an HTML/JS prototype), it returns mimeType: ‘text/html’.

The Security Challenge: We cannot use ”. It strips scripts. We cannot use bypassSecurityTrustHtml directly in the main DOM because the agent’s scripts would share our global scope (stealing tokens).

The Solution: The Sandboxed Iframe Pattern. We inject the code into an iframe using srcdoc.

// canvas/artifacts/code-artifact.component.ts 
import { Component, Input, computed, inject } from '@angular/core'; 
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; 
 
@Component({ 
  selector: 'app-code-artifact', 
  standalone: true, 
  template: ` 
     
      Preview: index.html 
       
       
     
  `, 
  styles: &#91;` 
   .browser-frame { display: flex; flex-direction: column; height: 100%; border: 1px solid #ccc; } 
   .address-bar { background: #eee; padding: 5px; font-family: monospace; font-size: 12px; border-bottom: 1px solid #ccc; } 
    iframe { flex: 1; background: white; } 
  `] 
}) 
export class CodeArtifactComponent { 
  private sanitizer = inject(DomSanitizer); 
  @Input() htmlCode = ''; 
 
  safeContent = computed(() => { 
    // We trust the HTML enough to put it in a SANDBOXED iframe. 
    return this.sanitizer.bypassSecurityTrustHtml(this.htmlCode); 
  }); 
}

**5.2 The Image Generation Agent (Blob Management) **

When an agent generates an image (e.g., via DALL-E or Stable Diffusion tools), it typically returns a Base64 string in the UIResource.

The Performance Challenge: Large Base64 strings (2MB+) in the DOM can cause significant layout thrashing and memory spikes if bound directly to [src]. The Solution: Convert the Base64 to a Blob and create an ephemeral Object URL. This moves the data out of the DOM string heap and into browser managed memory.

// canvas/artifacts/image-artifact.component.ts 
import { Component, Input, OnChanges, SimpleChanges, OnDestroy } from '@angular/core'; 
import { CommonModule } from '@angular/common'; 
 
@Component({ 
  selector: 'app-image-artifact', 
  standalone: true, 
  imports: &#91;CommonModule], 
  template: ` 
     
       
       
        Download 
       
     
  `, 
  styles: &#91;` 
   .image-viewer { text-align: center; padding: 20px; } 
    img { max-width: 100%; box-shadow: 0 4px 12px rgba(0,0,0,0.1); border-radius: 8px; } 
   .actions { margin-top: 15px; } 
  `] 
}) 
export class ImageArtifactComponent implements OnChanges, OnDestroy { 
  @Input() base64Data: string = ''; 
  @Input() mimeType: string = 'image/png'; 
   
  objectUrl: string | null = null; 
 
  ngOnChanges(changes: SimpleChanges) { 
    if (changes && this.base64Data) { 
      this.revokeUrl(); // Cleanup previous 
       
      // 1. Decode Base64 
      const byteCharacters = atob(this.base64Data); 
      const byteNumbers = new Array(byteCharacters.length); 
      for (let i = 0; i  
       
         
        Your browser does not support the video tag. 
       
     
  ` 
}) 
export class VideoArtifactComponent { 
  private sanitizer = inject(DomSanitizer); 
   
  @Input() videoUrl: string = ''; 
  @Input() mimeType: string = 'video/mp4'; 
  @Input() posterUrl: string = ''; 
 
  safeVideoUrl = computed(() => { 
    // We are explicitly trusting this URL.  
    // In a real app, you should validate the domain (e.g., only allow 'cdn.openai.com') 
    return this.sanitizer.bypassSecurityTrustResourceUrl(this.videoUrl); 
  }); 
}

6. Advanced Topics: Challenges and Future Outlook

6.1 Performance: Zone.js vs. Signals

In the code examples provided, we heavily favored Signals. This is intentional. The Vercel AI SDK’s streaming mechanism triggers an update for every token (approx. every 50ms). In a traditional Angular app using Zone.js, each update would trigger a check of the entire component tree (top-down).

  • Without Signals: High CPU usage, battery drain on mobile, potential UI stutter during generation.

  • With Signals: The messages signal updates. Angular’s reactive graph only marks the specific text node in the Chat Window as “dirty.” The rest of the app (the Layout, the Canvas, the Header) is untouched. This is critical for the “Canvas” architecture, where the right pane might contain heavy DOM elements (iframes, charts). 

6.2 The “Remote DOM” Limitation

While we solved text/html and media types, application/vnd.mcp-ui.remote-dom remains the hardest nut to crack for Angular. This specification is essentially “React Fiber over the wire.” It sends JSON patches to update a Virtual DOM. 

  • Constraint: There is no native Angular renderer for Remote DOM. 

  • Mitigation: The “Web Component” strategy (Section 4.3) is the only viable path today. It essentially embeds a React runtime inside your Angular app to handle just that specific resource. 

  • Future Outlook: As MCP matures, we expect the community to develop framework-agnostic renderers (likely based on standard Web Components rather than React specific protocols), which will lower the barrier for Angular adoption.

7. Conclusion

The path to building a Generative AI Canvas in Angular is challenging but achievable. The perception that “MCP-UI supports only React” is a tooling artifact, not a protocol limitation. By adopting a modern Angular architecture—leveraging Signals for high-frequency streaming state, Web Components for interoperability with React-based libraries, and Sanitized Iframes for secure execution—Angular architects can deliver Agentic interfaces that are robust, secure, and highly performant.

The code and strategies outlined in this report provide the blueprint for the next generation of enterprise interfaces: not static forms, but living, generative canvases where the user and the AI collaborate in real-time.

Topics:

Discuss this architectural approach.

Tell us what you'd like to build: an app, an agent, or a team alongside yours.