Skip to content

Commit cd5ef59

Browse files
committed
example dotnet angular host
1 parent 4f4dd8b commit cd5ef59

49 files changed

Lines changed: 10826 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Example: .NET + Angular Host
2+
3+
An MCP host implementation using Angular 21 (frontend) and ASP.NET Core .NET 10 (backend). Functionally equivalent to [`basic-host`](../basic-host), serving as a reference for teams building hosts with the Microsoft stack.
4+
5+
## Key Files
6+
7+
### Frontend (`frontend/`)
8+
9+
- [`src/app/app.ts`](frontend/src/app/app.ts) / [`app.html`](frontend/src/app/app.html) — Root component: server connections, tool call list, query-param deep linking
10+
- [`src/app/implementation.ts`](frontend/src/app/implementation.ts) — Core logic: server connection, tool calling, AppBridge setup
11+
- [`src/sandbox.ts`](frontend/src/sandbox.ts) — Compiled by esbuild into `sandbox.js`; loaded by the outer iframe proxy
12+
- [`public/sandbox.html`](frontend/public/sandbox.html) — Outer iframe proxy page (served from port 8081)
13+
14+
### Backend (`backend/`)
15+
16+
- [`Program.cs`](backend/Program.cs) — ASP.NET Core minimal API: dual-port Kestrel config, sandbox middleware, `/api/servers` endpoint, Angular SPA fallback
17+
- [`appsettings.json`](backend/appsettings.json) — Server URLs and port configuration
18+
19+
## Getting Started
20+
21+
### 1. Install dependencies
22+
23+
```bash
24+
# From the repo root
25+
npm install
26+
```
27+
28+
### 2. Build the frontend
29+
30+
```bash
31+
cd examples/dotnet-angular-host/frontend
32+
npm run build
33+
```
34+
35+
This runs `ng build` followed by an esbuild step that compiles `sandbox.ts` into `dist/browser/sandbox.js`.
36+
37+
### 3. Configure MCP servers
38+
39+
Edit [`backend/appsettings.json`](backend/appsettings.json) and set the `Servers` array to your MCP server URLs:
40+
41+
```json
42+
{
43+
"Servers": ["http://localhost:3001/mcp"]
44+
}
45+
```
46+
47+
### 4. Start the backend
48+
49+
```bash
50+
cd examples/dotnet-angular-host/backend
51+
dotnet run
52+
```
53+
54+
Open `http://localhost:8080`.
55+
56+
## Architecture
57+
58+
The host uses a double-iframe sandbox pattern for secure UI isolation:
59+
60+
```
61+
Host (port 8080)
62+
└── Outer iframe (port 8081) — sandbox proxy (sandbox.html / sandbox.js)
63+
└── Inner iframe (srcdoc) — untrusted tool UI
64+
```
65+
66+
**Why two iframes?**
67+
68+
- The outer iframe runs on a separate origin (port 8081), preventing direct DOM/cookie access to the host
69+
- The inner iframe receives HTML via `srcdoc` and is restricted by `sandbox` attributes
70+
- Messages flow through the outer iframe, which validates and relays them bidirectionally
71+
72+
The two ports are served by a single ASP.NET Core process using Kestrel's multi-listener configuration. Middleware branches on `context.Connection.LocalPort` to enforce the origin separation.
73+
74+
## Query Parameters
75+
76+
The host supports deep linking via URL query parameters:
77+
78+
| Parameter | Description |
79+
| --------- | --------------------------------------------- |
80+
| `server` | Pre-select a server by name |
81+
| `tool` | Pre-select a tool by name |
82+
| `call` | Set to `true` to auto-invoke the tool on load |
83+
| `theme` | Set to `hide` to hide the theme toggle button |
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/bin
2+
/obj
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
using Microsoft.Extensions.FileProviders;
4+
5+
var builder = WebApplication.CreateBuilder(args);
6+
7+
var hostPort = builder.Configuration.GetValue("HostPort", 8080);
8+
var sandboxPort = builder.Configuration.GetValue("SandboxPort", 8081);
9+
10+
// Kestrel: listen on two ports for separate origins (security requirement)
11+
builder.WebHost.ConfigureKestrel(options =>
12+
{
13+
options.ListenLocalhost(hostPort);
14+
options.ListenLocalhost(sandboxPort);
15+
});
16+
17+
builder.Services.AddCors(o =>
18+
o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
19+
20+
var app = builder.Build();
21+
app.UseCors();
22+
23+
// Resolve Angular dist directory
24+
var distRelPath = builder.Configuration["FrontendDistPath"] ?? "../frontend/dist/browser";
25+
var distPath = Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, distRelPath));
26+
27+
// ── Sandbox server (port 8081) ──────────────────────────────────────────────
28+
// Must run before static files middleware so it intercepts all sandbox-port requests.
29+
app.Use(async (context, next) =>
30+
{
31+
if (context.Connection.LocalPort != sandboxPort)
32+
{
33+
await next();
34+
return;
35+
}
36+
37+
var path = context.Request.Path.Value ?? "/";
38+
39+
if (path is "/" or "/sandbox.html")
40+
{
41+
McpUiResourceCsp? csp = null;
42+
if (context.Request.Query.TryGetValue("csp", out var cspJson))
43+
{
44+
try
45+
{
46+
csp = JsonSerializer.Deserialize<McpUiResourceCsp>(
47+
cspJson!,
48+
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
49+
}
50+
catch (JsonException ex)
51+
{
52+
Console.Error.WriteLine($"[Sandbox] Invalid CSP query param: {ex.Message}");
53+
}
54+
}
55+
56+
context.Response.Headers.ContentSecurityPolicy = BuildCspHeader(csp);
57+
context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate";
58+
context.Response.Headers.Pragma = "no-cache";
59+
context.Response.Headers.Expires = "0";
60+
context.Response.ContentType = "text/html";
61+
await context.Response.SendFileAsync(Path.Combine(distPath, "sandbox.html"));
62+
}
63+
else if (path == "/sandbox.js")
64+
{
65+
context.Response.ContentType = "application/javascript";
66+
await context.Response.SendFileAsync(Path.Combine(distPath, "sandbox.js"));
67+
}
68+
else
69+
{
70+
context.Response.StatusCode = 404;
71+
await context.Response.WriteAsync("Only sandbox files are served on this port");
72+
}
73+
});
74+
75+
// ── Host server (port 8080) ─────────────────────────────────────────────────
76+
77+
// Block sandbox files on host port — they must come from the sandbox origin.
78+
app.Use(async (context, next) =>
79+
{
80+
if (context.Request.Path.Value is "/sandbox.html" or "/sandbox.js")
81+
{
82+
context.Response.StatusCode = 404;
83+
await context.Response.WriteAsync("Sandbox is served on a different port");
84+
return;
85+
}
86+
87+
await next();
88+
});
89+
90+
// /api/servers — configuration required, no hardcoded fallback
91+
var servers = builder.Configuration.GetSection("Servers").Get<string[]>()
92+
?? throw new InvalidOperationException("'Servers' configuration is required in appsettings.json");
93+
94+
app.MapGet("/api/servers", () => Results.Json(servers));
95+
96+
// Angular SPA static files
97+
app.UseStaticFiles(new StaticFileOptions
98+
{
99+
FileProvider = new PhysicalFileProvider(distPath),
100+
});
101+
102+
// SPA fallback — serve index.html for unknown routes (client-side routing)
103+
app.MapFallback(async context =>
104+
{
105+
context.Response.ContentType = "text/html";
106+
await context.Response.SendFileAsync(Path.Combine(distPath, "index.html"));
107+
});
108+
109+
Console.WriteLine($"Host server: http://localhost:{hostPort}");
110+
Console.WriteLine($"Sandbox server: http://localhost:{sandboxPort}");
111+
Console.WriteLine($"Frontend dist: {distPath}");
112+
Console.WriteLine();
113+
114+
app.Run();
115+
116+
// ── CSP helpers ──────────────────────────────────────────────────────────────
117+
118+
static string BuildCspHeader(McpUiResourceCsp? csp)
119+
{
120+
var rd = string.Join(" ", SanitizeCspDomains(csp?.ResourceDomains));
121+
var cd = string.Join(" ", SanitizeCspDomains(csp?.ConnectDomains));
122+
var frame = csp?.FrameDomains?.Length > 0
123+
? $"frame-src {string.Join(" ", SanitizeCspDomains(csp.FrameDomains))}"
124+
: "frame-src 'none'";
125+
var baseUri = csp?.BaseUriDomains?.Length > 0
126+
? $"base-uri {string.Join(" ", SanitizeCspDomains(csp.BaseUriDomains))}"
127+
: "base-uri 'none'";
128+
129+
return string.Join("; ", new[]
130+
{
131+
"default-src 'self' 'unsafe-inline'",
132+
$"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: {rd}".TrimEnd(),
133+
$"style-src 'self' 'unsafe-inline' blob: data: {rd}".TrimEnd(),
134+
$"img-src 'self' data: blob: {rd}".TrimEnd(),
135+
$"font-src 'self' data: blob: {rd}".TrimEnd(),
136+
$"media-src 'self' data: blob: {rd}".TrimEnd(),
137+
$"connect-src 'self' {cd}".TrimEnd(),
138+
$"worker-src 'self' blob: {rd}".TrimEnd(),
139+
frame,
140+
"object-src 'none'",
141+
baseUri,
142+
});
143+
}
144+
145+
static string[] SanitizeCspDomains(string[]? domains) =>
146+
domains?
147+
.Where(d => !string.IsNullOrEmpty(d)
148+
&& !d.Any(c => c is ';' or '\r' or '\n' or '\'' or '"' or ' '))
149+
.ToArray() ?? [];
150+
151+
record McpUiResourceCsp(
152+
[property: JsonPropertyName("resourceDomains")] string[]? ResourceDomains,
153+
[property: JsonPropertyName("connectDomains")] string[]? ConnectDomains,
154+
[property: JsonPropertyName("frameDomains")] string[]? FrameDomains,
155+
[property: JsonPropertyName("baseUriDomains")] string[]? BaseUriDomains
156+
);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"$schema": "https://json.schemastore.org/launchsettings.json",
3+
"profiles": {
4+
"http": {
5+
"commandName": "Project",
6+
"dotnetRunMessages": true,
7+
"launchBrowser": true,
8+
"applicationUrl": "http://localhost:8080",
9+
"environmentVariables": {
10+
"ASPNETCORE_ENVIRONMENT": "Development"
11+
}
12+
}
13+
}
14+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
}
8+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
},
8+
"AllowedHosts": "*",
9+
"HostPort": 8080,
10+
"SandboxPort": 8081,
11+
"FrontendDistPath": "../frontend/dist/browser",
12+
"Servers": ["http://localhost:3001/mcp"]
13+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<RootNamespace>dotnet_host</RootNamespace>
8+
</PropertyGroup>
9+
10+
</Project>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Editor configuration, see https://editorconfig.org
2+
root = true
3+
4+
[*]
5+
charset = utf-8
6+
indent_style = space
7+
indent_size = 2
8+
insert_final_newline = true
9+
trim_trailing_whitespace = true
10+
11+
[*.ts]
12+
quote_type = single
13+
ij_typescript_use_double_quotes = false
14+
15+
[*.md]
16+
max_line_length = off
17+
trim_trailing_whitespace = false
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
2+
3+
/.angular
4+
5+
# Compiled output
6+
/dist
7+
/tmp
8+
/out-tsc
9+
/bazel-out
10+
11+
# Node
12+
/node_modules
13+
npm-debug.log
14+
yarn-error.log
15+
16+
# IDEs and editors
17+
.idea/
18+
.project
19+
.classpath
20+
.c9/
21+
*.launch
22+
.settings/
23+
*.sublime-workspace
24+
25+
# Visual Studio Code
26+
.vscode/*
27+
!.vscode/settings.json
28+
!.vscode/tasks.json
29+
!.vscode/launch.json
30+
!.vscode/extensions.json
31+
!.vscode/mcp.json
32+
.history/*
33+
34+
# Miscellaneous
35+
/.angular/cache
36+
.sass-cache/
37+
/connect.lock
38+
/coverage
39+
/libpeerconnection.log
40+
testem.log
41+
/typings
42+
__screenshots__/
43+
44+
# System files
45+
.DS_Store
46+
Thumbs.db
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"printWidth": 100,
3+
"singleQuote": true,
4+
"overrides": [
5+
{
6+
"files": "*.html",
7+
"options": {
8+
"parser": "angular"
9+
}
10+
}
11+
]
12+
}

0 commit comments

Comments
 (0)