HomeGetting startedHow it works

How it works

Why SSR gives you the server and not the render, and what happens in the sandboxed iframe of the host.

A view is not server-rendered HTML with the data already in it. The MCP host renders a thin HTML shell in a sandboxed iframe. The Angular widget bundle then boots, and the tool data arrives after that. A host bridge pushes the data. The first HTML does not carry it.

ng-mcp-ui is built around this sequence.

server.ts is one Express app

The MCP JSON-RPC endpoint (/mcp) and the widget asset routes mount before the SSR catch-all route of Angular.

import { createMcpExpressRouter, createViewAssetRouter } from "ng-mcp-ui/server";
import { createMcpServer } from "./mcp/server";

// before Angular's SSR catch-all:
app.use("/mcp", createMcpExpressRouter(createMcpServer()));
app.use("/assets/widgets", createViewAssetRouter({ dir: "dist/widgets/browser" }));

The order matters. A catch-all route that runs first answers /mcp with your application shell.

The package uses SSR for the thing SSR gives you here: an Express server. It does not use SSR to render the view content.

The endpoint holds no session

createMcpServer() returns a blueprint: it declares your tools and views, and it stores no request data. The router builds a fresh SDK server for each request from that blueprint, through McpServer.factory(). Therefore the endpoint scales to serverless and to several instances, and state that must survive a call travels on the wire. See sealed state.

The endpoint speaks MCP 2026-07-28 only. A client of the 2025 era gets an unsupported-protocol-version error that names the one supported revision. In this revision a host probes the capabilities of the server with server/discover. There is no initialize handshake.

A view is a client-bootstrapped widget

The standard Angular builder puts each registered view in its own lazy chunk, and the server sends that chunk over HTTP.

src/widgets/main.ts is the one browser entry. It reads the viewName value that the shell put on window.mcpUi, imports the matching registry entry, and boots it.

import { bootstrapWidget } from "ng-mcp-ui/web";
import { registry, type ViewName } from "./registry";

const injected = window.mcpUi as { viewName?: string } | undefined;
const name = (injected?.viewName ?? "echo") as ViewName;

registry[name]().then((m) => bootstrapWidget(m.default));

Each registry value is a dynamic import(). Therefore esbuild puts each view in its own hashed chunk, and the name of that chunk stays stable. The browser fetches the code of one view only.

The view manifest

A resources/read call must return a shell that names the hashed file names of the widget build. src/mcp/views.manifest.ts resolves those names, and it tolerates a missing build.

  • If dist/widgets/browser/index.html exists, IndexHtmlViewManifest parses it. The shell then names the real main-*.js and styles-*.css files.
  • If the file does not exist, an InMemoryViewManifest("main.js") still gives a correct shell. Thus a view boots before you have built the widget bundle one time.

One bridge, two hosts

One Adaptor interface covers the OpenAI Apps SDK (window.openai, which ChatGPT uses) and the open MCP-Apps postMessage specification (@modelcontextprotocol/ext-apps, which Claude and others use). Your widget code is the same for each host. See host bridge and adaptors.

Signals, not hooks

The view API is Angular-native: injectToolInfo(), injectCallTool(), injectViewState(), injectLayout(), a [dataLlm] directive and an mcpAsset pipe. Each one is signal-based, and none of them needs Zone.js.

Every inject* function resolves the host adaptor from the MCP_ADAPTOR DI token that provideMcpUi() supplies. None of them reads a global. Therefore one provider override replaces the host, which is how the test harness works.

A schematic does the wiring

ng add retrofits SSR, the MCP server and a widget build target. The view generator and the tool generator scaffold new views and tools, and they keep the registry and the ViewNameRegistry interface current. See schematics.

Build tooling

The Angular compiler ngc builds the library in partial compilation mode. One tsconfig.json file covers the four entry directories. The build does not use ng-packagr.

  • The Node-only entries, server and tunnel, emit as plain TypeScript.
  • The Angular entries, web and testing, emit Ivy partial declarations. The Angular linker of the application that consumes them completes those declarations at AOT build time. This is the contract for a published Angular library.
  • The exports field of package.json maps each subpath to its dist types and its default.

ng-packagr does not fit, because this is a hybrid package. Its server and tunnel entries import express, node:http and the MCP SDK.