diff --git a/README.md b/README.md
index 923cfc4..96c8367 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
**A .NET framework for building composable command surfaces.**
- Define your commands once — run them as a CLI, explore them in an interactive REPL,
-- host them in session-based terminals, expose them as MCP servers for AI agents,
+- host them in session-based terminals, expose them as MCP servers and MCP Apps for AI agents,
- or drive them from automation scripts.
> **New here?** The [DeepWiki](https://deepwiki.com/yllibed/repl) has full architecture docs, diagrams, and an AI assistant you can ask questions about the toolkit.
@@ -83,6 +83,14 @@ app.UseMcpServer(); // add one line
{ "command": "myapp", "args": ["mcp", "serve"] }
```
+**MCP Apps** (same server, host-rendered UI for capable clients):
+
+```csharp
+app.Map("contacts dashboard", (IContactStore contacts) => BuildHtml(contacts))
+ .WithDescription("Open the contacts dashboard")
+ .AsMcpAppResource();
+```
+
One command graph. CLI, REPL, remote sessions, and AI agents — all from the same code.
## What's included
@@ -93,7 +101,7 @@ One command graph. CLI, REPL, remote sessions, and AI agents — all from the sa
| Interactive REPL — scopes, history, autocomplete | [](https://www.nuget.org/packages/Repl.Defaults) |
|
-| MCP server — expose commands as AI agent tools | [](https://www.nuget.org/packages/Repl.Mcp) |
[MCP server](docs/mcp-server.md)
[MCP advanced](docs/mcp-advanced.md)
|
+| MCP server + MCP Apps — expose commands as agent tools, resources, prompts, and UI | [](https://www.nuget.org/packages/Repl.Mcp) |
|
@@ -115,7 +123,7 @@ Progressive learning path — each sample builds on the previous:
5. **[Hosting Remote](samples/05-hosting-remote/)** — WebSocket / Telnet session hosting
6. **[Testing](samples/06-testing/)** — multi-session typed assertions
7. **[Spectre](samples/07-spectre/)** — Spectre.Console renderables, visualizations, rich prompts
-8. **[MCP Server](samples/08-mcp-server/)** — expose commands as MCP tools for AI agents
+8. **[MCP Server](samples/08-mcp-server/)** — expose commands as MCP tools, resources, prompts, and a minimal MCP Apps UI
## More documentation
diff --git a/docs/architecture.md b/docs/architecture.md
index 0283a4b..fc3e2e8 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -20,7 +20,7 @@
- `Repl.Spectre`
- Spectre.Console integration: `SpectreInteractionHandler` for rich prompts, `IAnsiConsole` DI injection, `"spectre"` output transformer for auto-rendered tables, `SpectreConsoleOptions` for capability configuration.
- `Repl.Mcp`
- - MCP (Model Context Protocol) integration: `UseMcpServer()`, `BuildMcpServerOptions()`, tool/resource/prompt mapping, client roots, transport factory.
+ - MCP (Model Context Protocol) integration: `UseMcpServer()`, `BuildMcpServerOptions()`, tool/resource/prompt mapping, MCP Apps UI resources, client roots, transport factory.
- `Repl.Testing`
- In-memory multi-session testing toolkit (`ReplTestHost`, `ReplSessionHandle`, typed execution results/events).
- `Repl.Tests`
@@ -30,7 +30,7 @@
- `Repl.ProtocolTests`
- Contract tests for machine-readable help/error payloads.
- `Repl.McpTests`
- - Tests for MCP server options, tool mapping, and transport integration.
+ - Tests for MCP server options, tool/resource/prompt/app mapping, and transport integration.
- `Repl.SpectreTests`
- Tests for Spectre.Console integration.
- `Repl.ShellCompletionTestHost`
diff --git a/docs/best-practices.md b/docs/best-practices.md
index ace6bcd..b17609e 100644
--- a/docs/best-practices.md
+++ b/docs/best-practices.md
@@ -229,6 +229,16 @@ app.Map("clear", static async (IReplInteractionChannel ch, CancellationToken ct)
.AutomationHidden(); // not exposed to agents
```
+For MCP Apps, mark the HTML-producing command as an app resource:
+
+```csharp
+app.Map("contacts dashboard", static (IContactStore contacts) => BuildHtml(contacts))
+ .WithDescription("Open the contacts dashboard")
+ .AsMcpAppResource();
+```
+
+This lets capable hosts render the UI while keeping raw HTML out of the model-facing transcript. The handler is still a normal Repl mapping, so it can use DI, cancellation tokens, and the usual command pipeline.
+
Declare answer slots for interactive prompts so agents and `--answer:` flags can provide values:
```csharp
diff --git a/docs/comparison.md b/docs/comparison.md
index 02f1ac4..832e883 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -88,6 +88,8 @@ Repl Toolkit is a command-surface framework — not just a CLI parser. It builds
| Structured help output | ❌ Text only | ❌ Text only | ✅ JSON / XML / YAML |
| Documentation export | ❌ | ❌ | ✅ `doc export` command |
| Protocol passthrough (MCP, LSP...) | ❌ | ❌ | ✅ `AsProtocolPassthrough()` |
+| MCP server tools/resources/prompts | ❌ | ❌ | ✅ `Repl.Mcp` |
+| MCP Apps UI resources | ❌ | ❌ | ✅ `AsMcpAppResource()` |
| Shell completion | ⚠️ Tab completion API | ❌ | ✅ Bash, PS, Zsh, Fish, Nu |
## When to Use What
@@ -113,7 +115,7 @@ Repl Toolkit is a command-surface framework — not just a CLI parser. It builds
- Commands involve multi-step guided workflows (prompts, progress, confirmations)
- Remote terminal hosting is planned (WebSocket, Telnet)
- The command model must be testable in both one-shot and interactive contexts
-- AI/LLM agent readiness matters (structured help, protocol passthrough, pre-answered prompts)
+- AI/LLM agent readiness matters (structured help, MCP tools/resources/prompts, MCP Apps UI, protocol passthrough, pre-answered prompts)
## Migration from System.CommandLine
diff --git a/docs/glossary.md b/docs/glossary.md
index 5ba3b94..4c862cd 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -66,6 +66,10 @@ Static text in a route template matched exactly.
Model Context Protocol. Allows AI agents to discover and invoke commands.
+### MCP App
+
+MCP UI extension that lets a command open a `ui://` HTML resource. Repl maps this with `.AsMcpAppResource()` on the HTML-producing command and returns launcher text for normal tool calls.
+
### Middleware
Pipeline function registered via `app.Use()` that wraps handler execution.
diff --git a/docs/help-system.md b/docs/help-system.md
index 66b289d..3d7e1e1 100644
--- a/docs/help-system.md
+++ b/docs/help-system.md
@@ -56,7 +56,7 @@ Each `ReplDocCommand` includes:
This model powers:
- `--help` text rendering
-- MCP tool/resource/prompt schema generation
+- MCP tool/resource/prompt schema generation and MCP Apps metadata
- Shell completion candidate generation
See also: [Commands](commands.md) | [MCP Server](mcp-server.md) | [Parameter System](parameter-system.md)
diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md
index 4ca92cf..6115a26 100644
--- a/docs/mcp-advanced.md
+++ b/docs/mcp-advanced.md
@@ -1,4 +1,4 @@
-# MCP Advanced: Dynamic Tools, Roots, and Session-Aware Patterns
+# MCP Advanced: Dynamic Tools, Roots, MCP Apps, and Session-Aware Patterns
This guide covers advanced MCP usage patterns for Repl apps:
@@ -6,6 +6,7 @@ This guide covers advanced MCP usage patterns for Repl apps:
- Native MCP client roots
- Soft roots for clients that don't support roots
- Compatibility shims for clients that don't refresh dynamic tool lists well
+- Advanced MCP Apps patterns
> **Prerequisite**: read [mcp-server.md](mcp-server.md) first for the basic setup.
>
@@ -23,6 +24,7 @@ Use the techniques in this page when:
- The agent needs to know which directories it is allowed to work in
- Your MCP client does not support native roots
- Your MCP client does not seem to refresh its tool list after `list_changed`
+- Your MCP App should render HTML without exposing that HTML as the model-facing tool result
If your tool list is static, stay with the default setup from [mcp-server.md](mcp-server.md).
@@ -248,8 +250,142 @@ Avoid it when:
| Client supports tools but misses dynamic refreshes | Enable `DiscoverAndCallShim` |
| Client has both issues | Use soft roots and, if needed, the dynamic tool shim |
+## MCP Apps advanced patterns
+
+For the basic MCP Apps setup, start with [mcp-server.md](mcp-server.md#mcp-apps). This section covers the patterns that matter once the UI is more than a trivial inline HTML card.
+
+MCP Apps support is experimental in this version. Resource handlers should return generated HTML as `string`, `Task`, or `ValueTask`; the API is expected to become more flexible as host support and Repl's asset story evolve.
+
+### One mapping, two MCP surfaces
+
+`AsMcpAppResource()` keeps the Repl authoring model simple: one mapping produces both the launcher tool metadata and the UI resource.
+
+```csharp
+app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts))
+ .WithDescription("Open the contacts dashboard")
+ .AsMcpAppResource();
+```
+
+The handler return value is used for `resources/read` and is returned as `text/html;profile=mcp-app`. When a client calls the MCP tool, Repl returns launcher text instead of raw HTML, using `WithMcpAppLauncherText(...)`, `WithDescription(...)`, or a generated fallback.
+
+Use `WithMcpAppLauncherText(...)` when the description is not the text you want in the chat transcript:
+
+```csharp
+app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts))
+ .WithDescription("Open the contacts dashboard")
+ .AsMcpAppResource()
+ .WithMcpAppLauncherText("Opening the contacts dashboard.");
+```
+
+`WithMcpApp("ui://...")` remains available for advanced cases where a normal tool should point at a separately registered UI resource, but it is not the default pattern.
+
+```csharp
+app.Map("status dashboard", (IStatusStore store) => store.GetSummary())
+ .ReadOnly()
+ .WithMcpApp("ui://status/dashboard");
+```
+
+### Generated UI resource URIs
+
+`AsMcpAppResource()` generates a `ui://` URI from the route path, matching how `AsResource()` generates `repl://` URIs:
+
+```csharp
+app.Map("contact {id:int} panel", (int id, IContactDb contacts) => BuildHtml(contacts.Get(id)))
+ .AsMcpAppResource();
+```
+
+This produces a resource template like `ui://contact/{id}/panel`.
+
+The generated URI uses the full route path, including contexts:
+
+```csharp
+app.Context("viewer", viewer =>
+{
+ viewer.Context("session {id:int}", session =>
+ {
+ session.Map("attach", (int id) => BuildHtml(id))
+ .AsMcpAppResource();
+ });
+});
+```
+
+This produces `ui://viewer/session/{id}/attach`. MCP URI templates keep the variable name but not the Repl route constraint, so `{id:int}` becomes `{id}` in the URI and is validated when Repl dispatches the resource read through the normal command pipeline.
+
+Pass an explicit URI only when you need a stable public URI that is decoupled from the route path:
+
+```csharp
+app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts))
+ .AsMcpAppResource("ui://contacts/summary");
+```
+
+### Display preferences
+
+MCP Apps standard display modes are `inline`, `fullscreen`, and `pip`, but hosts decide what they support. Repl can express a preference:
+
+```csharp
+app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts))
+ .AsMcpAppResource()
+ .WithMcpAppDisplayMode(McpAppDisplayModes.Fullscreen);
+```
+
+As of April 2026, VS Code renders MCP Apps inline only. Microsoft 365 Copilot declarative agents support fullscreen display requests for widgets. Other hosts vary; check [mcp-server.md](mcp-server.md#mcp-apps-host-compatibility) for the current compatibility notes.
+
+If the HTML uses the MCP Apps JavaScript bridge, it should still ask the host what is available before requesting a different display mode:
+
+```javascript
+const modes = app.getHostContext()?.availableDisplayModes ?? [];
+if (modes.includes("fullscreen")) {
+ await app.requestDisplayMode({ mode: "fullscreen" });
+}
+```
+
+For host-specific hints that are not yet modeled by Repl, use simple string metadata:
+
+```csharp
+app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts))
+ .AsMcpAppResource()
+ .WithMcpAppUiMetadata("presentation", "flyout");
+```
+
+### HTML now, assets later
+
+The v1 Repl API expects the UI resource handler to return generated HTML. This is enough for small cards, forms, and proof-of-concept dashboards.
+
+For WebAssembly UIs such as Uno-Wasm, the likely shape is:
+
+1. Map a `ui://` app resource that returns a small HTML shell.
+2. Serve published static assets such as `embedded.js`, `_framework/*`, `.wasm`, fonts, and images from an HTTP endpoint.
+3. Inject the HTTP base URL into the generated shell.
+4. Set CSP metadata for asset and fetch domains.
+
+```csharp
+var assetBaseUri = new Uri("http://127.0.0.1:5123/");
+
+app.Map("contacts dashboard", () => BuildUnoShellHtml(assetBaseUri))
+ .AsMcpAppResource()
+ .WithMcpAppCsp(new McpAppCsp
+ {
+ ResourceDomains = [assetBaseUri.ToString()],
+ ConnectDomains = [assetBaseUri.ToString()],
+ });
+```
+
+Keep the shell and asset server host-aware: clients may preload or cache UI resources, and not every host supports every display mode or browser capability.
+
## Troubleshooting patterns
+### My MCP App shows HTML text in the chat
+
+Use `.AsMcpAppResource()` on the HTML-producing command instead of linking a normal tool to raw HTML manually. Repl will return launcher text for tool calls and reserve the HTML for `resources/read`.
+
+Also restart or reload the MCP server in the client. Some hosts cache tool lists and will not pick up MCP Apps metadata changes until the server is refreshed.
+
+### My MCP App does not open fullscreen
+
+Check whether the host supports fullscreen. VS Code currently renders MCP Apps inline only, even when Repl sets `preferredDisplayMode: McpAppDisplayModes.Fullscreen`.
+
+For hosts that support display mode changes, request fullscreen from inside the HTML app after checking host capabilities.
+
### The agent doesn't see tools that should appear later
Check:
diff --git a/docs/mcp-server.md b/docs/mcp-server.md
index 8dc8091..3417d0b 100644
--- a/docs/mcp-server.md
+++ b/docs/mcp-server.md
@@ -2,11 +2,11 @@
Expose your Repl command graph as an [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server so AI agents can discover and invoke your commands as typed tools.
-See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working demo.
+See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working MCP server demo with a minimal inline MCP Apps UI.
Related guides:
-- [mcp-advanced.md](mcp-advanced.md) for roots, soft roots, and dynamic tool patterns
+- [mcp-advanced.md](mcp-advanced.md) for roots, soft roots, dynamic tool patterns, and advanced MCP Apps patterns
- [mcp-transports.md](mcp-transports.md) for custom transports and HTTP hosting
- [mcp-internals.md](mcp-internals.md) for concepts and under-the-hood behavior
@@ -50,6 +50,7 @@ Commands map to MCP primitives:
| `Map().AsResource()` | Resource | Explicit — marks data-to-consult |
| `.ReadOnly()` | Resource (auto-promoted) | ReadOnly tools are also exposed as resources |
| `Map().AsPrompt()` | Prompt | Explicit — handler return becomes prompt template |
+| `Map().AsMcpAppResource()` | MCP App UI resource | Explicit — handler return becomes `text/html;profile=mcp-app` behind a `ui://` URI |
| `options.Prompt()` | Prompt | Explicit — registered in `ReplMcpServerOptions` |
## Annotations
@@ -157,6 +158,7 @@ app.Map("deploy {env}", handler)
| `.ReadOnly().AsResource()` | Yes | Yes | No |
| `.AsPrompt()` | No | No | Yes |
| `.AsPrompt()` + `PromptFallbackToTools = true` | Yes | No | Yes |
+| `.AsMcpAppResource()` | Yes (launcher text) | Yes (`ui://` HTML resource) | No |
| `.AutomationHidden()` | No | No | No |
> **Compatibility fallback:** Since only ~39% of clients support resources and ~38% support prompts, you can opt in to expose them as tools too. Enable `ResourceFallbackToTools` and/or `PromptFallbackToTools` in `ReplMcpServerOptions`. `AutoPromoteReadOnlyToResources` (default: `true`) controls whether `.ReadOnly()` commands are automatically exposed as resources.
@@ -170,6 +172,57 @@ app.Map("deploy {env}", handler)
> });
> ```
+## MCP Apps
+
+MCP Apps let a tool render an interactive HTML UI in clients that support the `io.modelcontextprotocol/ui` extension. Repl.Mcp exposes this through `ui://` resources and tool metadata.
+
+> **Experimental:** MCP Apps support is intentionally small in this version. `AsMcpAppResource()` handlers should return generated HTML as `string`, `Task`, or `ValueTask`. Richer return types, static asset helpers, and WebAssembly-oriented hosting may be added as the feature matures.
+
+```csharp
+app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts))
+ .WithDescription("Open the contacts dashboard")
+ .AsMcpAppResource()
+ .WithMcpAppBorder();
+```
+
+What happens:
+
+- `AsMcpAppResource()` maps the command as a `ui://` HTML resource and adds the tool metadata that lets capable hosts render it.
+- The HTML command handler runs through the normal Repl pipeline, so services can be injected just like other mapped commands.
+- Tool calls return launcher text, not raw HTML.
+- `resources/read` returns `text/html;profile=mcp-app`.
+- CSP, permissions, borders, and domain hints are emitted as `_meta.ui` on the UI resource content, not on the launcher tool result.
+- Clients that support MCP Apps render the HTML.
+- Clients that do not support MCP Apps ignore the UI metadata and still receive the tool's normal text result.
+
+Hosts decide which display modes they support; standard MCP Apps display mode values are `inline`, `fullscreen`, and `pip`.
+See [mcp-advanced.md](mcp-advanced.md#mcp-apps-advanced-patterns) for generated URIs, display modes, and WebAssembly assets.
+
+For UI that loads external assets, declare the domains with CSP metadata:
+
+```csharp
+app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts))
+ .AsMcpAppResource()
+ .WithMcpAppCsp(new McpAppCsp
+ {
+ ResourceDomains = ["https://cdn.example.com"],
+ ConnectDomains = ["https://api.example.com"],
+ });
+```
+
+Pass an explicit URI when you need a stable custom value:
+
+```csharp
+app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts))
+ .AsMcpAppResource("ui://contacts/summary");
+```
+
+When no explicit URI is provided, Repl generates a `ui://` template from the full route path, including nested contexts. For example, `viewer session {id:int} attach` becomes `ui://viewer/session/{id}/attach`. MCP URI templates do not encode route constraints, so Repl validates `{id:int}` when the resource read is dispatched through the command pipeline.
+
+For advanced cases where the UI resource is not backed by a Repl command, `ReplMcpServerOptions.UiResource(...)` can register a raw `ui://` HTML resource directly.
+
+For WebAssembly UIs such as Uno-Wasm, serve the published assets from an HTTP endpoint and inject that endpoint into the generated HTML. The `ui://` resource should return the shell HTML, while the HTTP server serves assets such as `embedded.js`, `_framework/*`, `.wasm`, fonts, and other static files.
+
## JSON Schema generation
Route constraints and handler parameter types produce typed JSON Schema:
@@ -377,8 +430,10 @@ app.UseMcpServer(o =>
o.ResourceFallbackToTools = false; // opt-in: also expose resources as tools
o.PromptFallbackToTools = false; // opt-in: also expose prompts as tools
o.DynamicToolCompatibility = DynamicToolCompatibilityMode.Disabled; // opt-in shim for clients that miss dynamic tool refresh
+ o.EnableApps = false; // usually enabled automatically by MCP App mappings
o.CommandFilter = cmd => true; // filter which commands become tools
o.Prompt("summarize", (string topic) => ...); // explicit prompt registration
+ o.UiResource("ui://custom/app", () => "..."); // advanced: raw MCP App HTML resource
});
```
@@ -401,6 +456,18 @@ Feature support varies across agent clients. The table below reflects the state
- `PrefillThenElicitation` provides the best UX but requires elicitation support, degrading gracefully through sampling then failure
- Resources should be annotated `.ReadOnly()` as well, so they're always accessible as tools even when the client doesn't support resources
+### MCP Apps host compatibility
+
+MCP Apps host support is newer and changes quickly. As of **April 2026**, known public behavior is:
+
+| Host | MCP Apps UI | `fullscreen` | `pip` | Notes |
+|---|---|---|---|---|
+| VS Code Copilot | Yes | No | No | Renders MCP Apps inline in chat only; see the [VS Code MCP developer guide](https://code.visualstudio.com/api/extension-guides/ai/mcp) |
+| Microsoft 365 Copilot declarative agents | Yes | Yes | No | Supports display mode requests for fullscreen widgets; see [Microsoft 365 Copilot UI widgets](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/declarative-agent-ui-widgets) |
+| Other MCP Apps hosts | Varies | Varies | Varies | Check `availableDisplayModes` or gracefully fall back to inline |
+
+`preferredDisplayMode` in Repl is a host-facing preference, not a guarantee. If your HTML app uses the MCP Apps bridge, it should still check host capabilities before requesting a display mode.
+
## Agent configuration
### Claude Desktop
diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs
index b29127b..36d4f84 100644
--- a/samples/08-mcp-server/Program.cs
+++ b/samples/08-mcp-server/Program.cs
@@ -1,4 +1,6 @@
using System.ComponentModel;
+using System.Net;
+using Microsoft.Extensions.DependencyInjection;
using Repl;
using Repl.Mcp;
@@ -10,27 +12,59 @@
// Configure in Claude Desktop or VS Code:
// { "command": "dotnet", "args": ["run", "--project", "path/to/08-mcp-server", "--", "mcp", "serve"] }
-var app = ReplApp.Create().UseDefaultInteractive();
+var app = ReplApp.Create(services =>
+{
+ services.AddSingleton();
+}).UseDefaultInteractive();
-app.UseMcpServer(o => o.ServerName = "ContactManager");
+app.UseMcpServer(o =>
+{
+ o.ServerName = "ContactManager";
+});
// ── Resources (data to consult) ────────────────────────────────────
-app.Map("contacts", () => new[]
- {
- new { Name = "Alice", Email = "alice@example.com" },
- new { Name = "Bob", Email = "bob@example.com" },
- })
+app.Map("contacts", (ContactStore contacts) => contacts.All)
.WithDescription("List all contacts")
.ReadOnly()
.AsResource();
+app.Map("contacts dashboard", (ContactStore contacts) =>
+ {
+ var items = string.Join(
+ "",
+ contacts.All.Select(static contact =>
+ $"
{Html(contact.Name)}{Html(contact.Email)}
"));
+
+ return $$"""
+
+
+
+
+
+
+
+
Contacts from Repl
+
This HTML was rendered from a ui:// MCP resource.
+
{{items}}
+
+
+ """;
+ })
+ .WithDescription("Open the contacts dashboard")
+ .AsMcpAppResource()
+ .WithMcpAppBorder()
+ .WithMcpAppDisplayMode(McpAppDisplayModes.Fullscreen);
+
// ── Contact operations (grouped context) ───────────────────────────
app.Context("contact", contact =>
{
- contact.Map("{id:int}", ([Description("Contact numeric id")] int id) =>
- new { Id = id, Name = id == 1 ? "Alice" : "Bob", Email = $"user{id}@example.com" })
+ contact.Map("{id:int}", ([Description("Contact numeric id")] int id, ContactStore contacts) =>
+ new { Id = id, Contact = contacts.Get(id) })
.WithDescription("Get contact by ID")
.ReadOnly();
@@ -81,3 +115,22 @@ The email must be unique across all contacts.
.AutomationHidden();
return app.Run(args);
+
+static string Html(string value) => WebUtility.HtmlEncode(value);
+
+internal sealed record Contact(string Name, string Email);
+
+internal sealed class ContactStore
+{
+ private readonly Contact[] _contacts =
+ [
+ new("Alice", "alice@example.com"),
+ new("Bob", "bob@example.com"),
+ ];
+
+ public IReadOnlyList All => _contacts;
+
+ public Contact? Get(int id) => id >= 1 && id <= _contacts.Length
+ ? _contacts[id - 1]
+ : null;
+}
diff --git a/samples/08-mcp-server/README.md b/samples/08-mcp-server/README.md
index f28f2a7..e88089a 100644
--- a/samples/08-mcp-server/README.md
+++ b/samples/08-mcp-server/README.md
@@ -1,6 +1,6 @@
# 08 — MCP Server
-Expose a Repl command graph as an MCP server for AI agents.
+Expose a Repl command graph as an MCP server for AI agents, including a minimal MCP Apps UI.
## What this sample shows
@@ -8,6 +8,8 @@ Expose a Repl command graph as an MCP server for AI agents.
- `.ReadOnly()` / `.Destructive()` / `.OpenWorld()` — behavioral annotations
- `.AsResource()` — mark data-to-consult commands as MCP resources
- `.AsPrompt()` — mark commands as MCP prompt sources
+- `.AsMcpAppResource()` — mark a command as a generated HTML MCP App resource
+- `.WithMcpAppBorder()` / `.WithMcpAppDisplayMode(...)` — add MCP Apps presentation preferences
- `.AutomationHidden()` — hide interactive-only commands from agents
- `.WithDetails()` — rich descriptions that serve both `--help` and agents
@@ -31,6 +33,10 @@ dotnet run -- mcp serve
npx @modelcontextprotocol/inspector dotnet run --project . -- mcp serve
```
+Clients with MCP Apps support render the `contacts dashboard` tool's generated `ui://contacts/dashboard` resource. Other clients still receive the normal launcher text instead of raw HTML.
+
+In the current Repl.Mcp version, MCP Apps are experimental and the UI handler returns generated HTML as a string. Future versions may add richer return types and asset helpers.
+
## Agent configuration
### Claude Desktop
diff --git a/samples/README.md b/samples/README.md
index dca44e8..02bf051 100644
--- a/samples/README.md
+++ b/samples/README.md
@@ -20,6 +20,8 @@ If you’re new, start with **01**, then follow the sequence.
`Repl.Testing` harness: multi-step + multi-session, typed results, interaction/timeline events, metadata snapshots.
7. [07 — Spectre](07-spectre/)
`Repl.Spectre` integration: FigletText, Table, Panel, Tree, BarChart, BreakdownChart, Calendar, JsonText, TextPath, Grid, Columns, Rule, Status, Progress, and all Spectre-powered prompts.
+8. [08 — MCP Server](08-mcp-server/)
+ MCP server mode: tools, resources, prompts, behavioral annotations, automation visibility, and a minimal MCP Apps UI.
## Run
@@ -37,6 +39,7 @@ Replace the project path with the one you want:
- `samples/05-hosting-remote/HostingRemoteSample.csproj`
- `samples/06-testing/TestingSample.csproj`
- `samples/07-spectre/SpectreOpsSample.csproj`
+- `samples/08-mcp-server/McpServerSample.csproj`
## Suggested reading (existing docs)
diff --git a/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs b/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs
new file mode 100644
index 0000000..eba1438
--- /dev/null
+++ b/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs
@@ -0,0 +1,170 @@
+namespace Repl.Mcp;
+
+///
+/// Extension methods for linking Repl commands to MCP App UI resources.
+///
+public static class McpAppCommandBuilderExtensions
+{
+ ///
+ /// Links a command to an MCP App UI resource.
+ ///
+ /// Command builder.
+ /// The ui:// resource rendered for this command.
+ /// Whether the tool is visible to the model, the app iframe, or both.
+ /// The same builder instance.
+ public static CommandBuilder WithMcpApp(
+ this CommandBuilder builder,
+ string resourceUri,
+ McpAppVisibility visibility = McpAppVisibility.ModelAndApp)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ McpAppValidation.ThrowIfInvalidUiUri(resourceUri);
+ return builder.WithMetadata(
+ McpAppMetadata.CommandMetadataKey,
+ new McpAppToolOptions(resourceUri) { Visibility = visibility });
+ }
+
+ ///
+ /// Marks this command as an MCP App UI resource and links the command's tool declaration to it.
+ /// The ui:// resource URI is generated from the command route.
+ /// The handler return value should be a complete HTML document.
+ ///
+ /// Command builder.
+ /// Whether the linked tool is visible to the model, the app iframe, or both.
+ /// Optional preferred display mode. Hosts decide whether they support it.
+ /// The same builder instance.
+ public static CommandBuilder AsMcpAppResource(
+ this CommandBuilder builder,
+ McpAppVisibility visibility = McpAppVisibility.ModelAndApp,
+ string? preferredDisplayMode = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var resourceUri = McpToolNameFlattener.BuildResourceUri(builder.Route, "ui");
+ return builder.AsMcpAppResource(resourceUri, visibility, preferredDisplayMode);
+ }
+
+ ///
+ /// Marks this command as an MCP App UI resource and links the command's tool declaration to it.
+ /// The handler return value should be a complete HTML document.
+ ///
+ /// Command builder.
+ /// The ui:// resource URI.
+ /// Whether the linked tool is visible to the model, the app iframe, or both.
+ /// Optional preferred display mode. Hosts decide whether they support it.
+ /// The same builder instance.
+ public static CommandBuilder AsMcpAppResource(
+ this CommandBuilder builder,
+ string resourceUri,
+ McpAppVisibility visibility = McpAppVisibility.ModelAndApp,
+ string? preferredDisplayMode = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ McpAppValidation.ThrowIfInvalidUiUri(resourceUri);
+
+ var options = new McpAppResourceOptions();
+ options.PreferredDisplayMode ??= preferredDisplayMode;
+
+ builder
+ .ReadOnly()
+ .AsResource()
+ .WithMetadata(
+ McpAppMetadata.ResourceMetadataKey,
+ new McpAppCommandResourceOptions(resourceUri, options, visibility));
+
+ return builder;
+ }
+
+ ///
+ /// Sets the fallback text returned when the MCP App launcher tool is called.
+ ///
+ public static CommandBuilder WithMcpAppLauncherText(this CommandBuilder builder, string text)
+ {
+ GetResourceOptions(builder).LauncherText = string.IsNullOrWhiteSpace(text)
+ ? throw new ArgumentException("Launcher text cannot be empty.", nameof(text))
+ : text;
+ return builder;
+ }
+
+ ///
+ /// Sets the visual boundary preference for the MCP App.
+ ///
+ public static CommandBuilder WithMcpAppBorder(this CommandBuilder builder, bool prefersBorder = true)
+ {
+ GetResourceOptions(builder).PrefersBorder = prefersBorder;
+ return builder;
+ }
+
+ ///
+ /// Sets the preferred display mode for hosts that support display mode changes.
+ ///
+ public static CommandBuilder WithMcpAppDisplayMode(this CommandBuilder builder, string displayMode)
+ {
+ GetResourceOptions(builder).PreferredDisplayMode = string.IsNullOrWhiteSpace(displayMode)
+ ? throw new ArgumentException("Display mode cannot be empty.", nameof(displayMode))
+ : displayMode;
+ return builder;
+ }
+
+ ///
+ /// Sets the Content Security Policy metadata for the MCP App.
+ ///
+ public static CommandBuilder WithMcpAppCsp(this CommandBuilder builder, McpAppCsp csp)
+ {
+ ArgumentNullException.ThrowIfNull(csp);
+ GetResourceOptions(builder).Csp = csp;
+ return builder;
+ }
+
+ ///
+ /// Sets a host-specific dedicated domain hint for the MCP App.
+ ///
+ public static CommandBuilder WithMcpAppDomain(this CommandBuilder builder, string domain)
+ {
+ GetResourceOptions(builder).Domain = string.IsNullOrWhiteSpace(domain)
+ ? throw new ArgumentException("Domain cannot be empty.", nameof(domain))
+ : domain;
+ return builder;
+ }
+
+ ///
+ /// Sets browser permission metadata for the MCP App.
+ ///
+ public static CommandBuilder WithMcpAppPermissions(
+ this CommandBuilder builder,
+ McpAppPermissions permissions)
+ {
+ ArgumentNullException.ThrowIfNull(permissions);
+ GetResourceOptions(builder).Permissions = permissions;
+ return builder;
+ }
+
+ ///
+ /// Adds a host-specific UI metadata value.
+ ///
+ public static CommandBuilder WithMcpAppUiMetadata(
+ this CommandBuilder builder,
+ string key,
+ string value)
+ {
+ key = string.IsNullOrWhiteSpace(key)
+ ? throw new ArgumentException("Metadata key cannot be empty.", nameof(key))
+ : key;
+ value = string.IsNullOrWhiteSpace(value)
+ ? throw new ArgumentException("Metadata value cannot be empty.", nameof(value))
+ : value;
+ GetResourceOptions(builder).UiMetadata[key] = value;
+ return builder;
+ }
+
+ private static McpAppResourceOptions GetResourceOptions(CommandBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ if (builder.Metadata.TryGetValue(McpAppMetadata.ResourceMetadataKey, out var value)
+ && value is McpAppCommandResourceOptions options)
+ {
+ return options.ResourceOptions;
+ }
+
+ throw new InvalidOperationException("Call AsMcpAppResource() before configuring MCP App metadata.");
+ }
+}
diff --git a/src/Repl.Mcp/McpAppCommandResourceOptions.cs b/src/Repl.Mcp/McpAppCommandResourceOptions.cs
new file mode 100644
index 0000000..d63f44f
--- /dev/null
+++ b/src/Repl.Mcp/McpAppCommandResourceOptions.cs
@@ -0,0 +1,6 @@
+namespace Repl.Mcp;
+
+internal sealed record McpAppCommandResourceOptions(
+ string ResourceUri,
+ McpAppResourceOptions ResourceOptions,
+ McpAppVisibility Visibility);
diff --git a/src/Repl.Mcp/McpAppCsp.cs b/src/Repl.Mcp/McpAppCsp.cs
new file mode 100644
index 0000000..daef852
--- /dev/null
+++ b/src/Repl.Mcp/McpAppCsp.cs
@@ -0,0 +1,27 @@
+namespace Repl.Mcp;
+
+///
+/// Content Security Policy domains requested by an MCP App resource.
+///
+public sealed record McpAppCsp
+{
+ ///
+ /// Origins allowed for fetch, XHR, and WebSocket connections.
+ ///
+ public IReadOnlyList? ConnectDomains { get; init; }
+
+ ///
+ /// Origins allowed for images, scripts, stylesheets, fonts, and media.
+ ///
+ public IReadOnlyList? ResourceDomains { get; init; }
+
+ ///
+ /// Origins allowed for nested iframes.
+ ///
+ public IReadOnlyList? FrameDomains { get; init; }
+
+ ///
+ /// Origins allowed as document base URIs.
+ ///
+ public IReadOnlyList? BaseUriDomains { get; init; }
+}
diff --git a/src/Repl.Mcp/McpAppDisplayModes.cs b/src/Repl.Mcp/McpAppDisplayModes.cs
new file mode 100644
index 0000000..9afb69c
--- /dev/null
+++ b/src/Repl.Mcp/McpAppDisplayModes.cs
@@ -0,0 +1,16 @@
+namespace Repl.Mcp;
+
+///
+/// Standard MCP Apps display mode values.
+///
+public static class McpAppDisplayModes
+{
+ /// Render the app inline in the conversation surface.
+ public const string Inline = "inline";
+
+ /// Render the app in a fullscreen presentation surface, when supported by the host.
+ public const string Fullscreen = "fullscreen";
+
+ /// Render the app in picture-in-picture mode, when supported by the host.
+ public const string PictureInPicture = "pip";
+}
diff --git a/src/Repl.Mcp/McpAppMetadata.cs b/src/Repl.Mcp/McpAppMetadata.cs
new file mode 100644
index 0000000..5296318
--- /dev/null
+++ b/src/Repl.Mcp/McpAppMetadata.cs
@@ -0,0 +1,138 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Repl.Mcp;
+
+internal static class McpAppMetadata
+{
+ public const string CommandMetadataKey = "Repl.Mcp.App";
+ public const string ResourceMetadataKey = "Repl.Mcp.AppResource";
+ public const string ExtensionName = "io.modelcontextprotocol/ui";
+
+ public static JsonObject BuildToolMeta(McpAppToolOptions options)
+ {
+ var ui = new JsonObject
+ {
+ ["resourceUri"] = options.ResourceUri,
+ ["visibility"] = BuildVisibilityArray(options.Visibility),
+ };
+
+ return new JsonObject { ["ui"] = ui };
+ }
+
+ public static JsonObject? BuildResourceMeta(McpAppResourceOptions options)
+ {
+ var ui = new JsonObject();
+ foreach (var (key, value) in options.UiMetadata)
+ {
+ if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value))
+ {
+ ui[key] = value;
+ }
+ }
+
+ if (BuildCsp(options.Csp) is { } csp)
+ {
+ ui["csp"] = csp;
+ }
+
+ if (BuildPermissions(options.Permissions) is { } permissions)
+ {
+ ui["permissions"] = permissions;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.Domain))
+ {
+ ui["domain"] = options.Domain;
+ }
+
+ if (options.PrefersBorder is { } prefersBorder)
+ {
+ ui["prefersBorder"] = prefersBorder;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.PreferredDisplayMode))
+ {
+ ui["preferredDisplayMode"] = options.PreferredDisplayMode;
+ }
+
+ return ui.Count == 0
+ ? null
+ : new JsonObject { ["ui"] = ui };
+ }
+
+ private static JsonArray BuildVisibilityArray(McpAppVisibility visibility)
+ {
+ var values = new List();
+ if (visibility.HasFlag(McpAppVisibility.Model))
+ {
+ values.Add("model");
+ }
+
+ if (visibility.HasFlag(McpAppVisibility.App))
+ {
+ values.Add("app");
+ }
+
+ return JsonSerializer.SerializeToNode(
+ values.ToArray(),
+ McpJsonContext.Default.StringArray)!.AsArray();
+ }
+
+ private static JsonObject? BuildCsp(McpAppCsp? csp)
+ {
+ if (csp is null)
+ {
+ return null;
+ }
+
+ var node = new JsonObject();
+ AddStringArray(node, "connectDomains", csp.ConnectDomains);
+ AddStringArray(node, "resourceDomains", csp.ResourceDomains);
+ AddStringArray(node, "frameDomains", csp.FrameDomains);
+ AddStringArray(node, "baseUriDomains", csp.BaseUriDomains);
+ return node.Count == 0 ? null : node;
+ }
+
+ private static JsonObject? BuildPermissions(McpAppPermissions? permissions)
+ {
+ if (permissions is null)
+ {
+ return null;
+ }
+
+ var node = new JsonObject();
+ AddPermission(node, "camera", permissions.Camera);
+ AddPermission(node, "microphone", permissions.Microphone);
+ AddPermission(node, "geolocation", permissions.Geolocation);
+ AddPermission(node, "clipboardWrite", permissions.ClipboardWrite);
+ return node.Count == 0 ? null : node;
+ }
+
+ private static void AddPermission(JsonObject node, string propertyName, bool value)
+ {
+ if (value)
+ {
+ node[propertyName] = new JsonObject();
+ }
+ }
+
+ private static void AddStringArray(JsonObject node, string propertyName, IReadOnlyList? values)
+ {
+ if (values is null || values.Count == 0)
+ {
+ return;
+ }
+
+ var normalized = values
+ .Where(static value => !string.IsNullOrWhiteSpace(value))
+ .ToArray();
+
+ if (normalized.Length > 0)
+ {
+ node[propertyName] = JsonSerializer.SerializeToNode(
+ normalized,
+ McpJsonContext.Default.StringArray);
+ }
+ }
+}
diff --git a/src/Repl.Mcp/McpAppPermissions.cs b/src/Repl.Mcp/McpAppPermissions.cs
new file mode 100644
index 0000000..2676380
--- /dev/null
+++ b/src/Repl.Mcp/McpAppPermissions.cs
@@ -0,0 +1,19 @@
+namespace Repl.Mcp;
+
+///
+/// Browser permissions requested by an MCP App resource.
+///
+public sealed record McpAppPermissions
+{
+ /// Requests camera access.
+ public bool Camera { get; init; }
+
+ /// Requests microphone access.
+ public bool Microphone { get; init; }
+
+ /// Requests geolocation access.
+ public bool Geolocation { get; init; }
+
+ /// Requests clipboard write access.
+ public bool ClipboardWrite { get; init; }
+}
diff --git a/src/Repl.Mcp/McpAppResource.cs b/src/Repl.Mcp/McpAppResource.cs
new file mode 100644
index 0000000..f9370aa
--- /dev/null
+++ b/src/Repl.Mcp/McpAppResource.cs
@@ -0,0 +1,60 @@
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Repl.Mcp;
+
+internal sealed class McpAppResource : McpServerResource
+{
+ private readonly McpAppResourceRegistration _registration;
+ private readonly IServiceProvider _services;
+ private readonly ResourceTemplate _protocolResourceTemplate;
+
+ public McpAppResource(McpAppResourceRegistration registration, IServiceProvider services)
+ {
+ _registration = registration;
+ _services = services;
+ _protocolResourceTemplate = new ResourceTemplate
+ {
+ Name = registration.Options.Name ?? registration.Uri,
+ Description = registration.Options.Description,
+ UriTemplate = registration.Uri,
+ MimeType = McpAppValidation.ResourceMimeType,
+ Meta = McpAppMetadata.BuildResourceMeta(registration.Options),
+ };
+ }
+
+ public override ResourceTemplate ProtocolResourceTemplate => _protocolResourceTemplate;
+
+ public override IReadOnlyList