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.htmlexists,IndexHtmlViewManifestparses it. The shell then names the realmain-*.jsandstyles-*.cssfiles. - 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,
serverandtunnel, emit as plain TypeScript. - The Angular entries,
webandtesting, 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
exportsfield ofpackage.jsonmaps each subpath to itsdisttypes 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.