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 | [![Repl.Defaults](https://img.shields.io/nuget/vpre/Repl.Defaults?logo=nuget&label=Repl.Defaults)](https://www.nuget.org/packages/Repl.Defaults) | | | Parameters & options — typed binding, options groups, response files | [![Repl.Core](https://img.shields.io/nuget/vpre/Repl.Core?logo=nuget&label=Repl.Core)](https://www.nuget.org/packages/Repl.Core) | | | Multiple output formats — JSON, XML, YAML, Markdown | [![Repl.Core](https://img.shields.io/nuget/vpre/Repl.Core?logo=nuget&label=Repl.Core)](https://www.nuget.org/packages/Repl.Core) | | -| MCP server — expose commands as AI agent tools | [![Repl.Mcp](https://img.shields.io/nuget/vpre/Repl.Mcp?logo=nuget&label=Repl.Mcp)](https://www.nuget.org/packages/Repl.Mcp) | | +| MCP server + MCP Apps — expose commands as agent tools, resources, prompts, and UI | [![Repl.Mcp](https://img.shields.io/nuget/vpre/Repl.Mcp?logo=nuget&label=Repl.Mcp)](https://www.nuget.org/packages/Repl.Mcp) | | | Typed results & interactions — prompts, progress, cancellation | [![Repl.Core](https://img.shields.io/nuget/vpre/Repl.Core?logo=nuget&label=Repl.Core)](https://www.nuget.org/packages/Repl.Core) | | | Session hosting — WebSocket, Telnet, remote terminals | [![Repl.WebSocket](https://img.shields.io/nuget/vpre/Repl.WebSocket?logo=nuget&label=Repl.WebSocket)](https://www.nuget.org/packages/Repl.WebSocket) [![Repl.Telnet](https://img.shields.io/nuget/vpre/Repl.Telnet?logo=nuget&label=Repl.Telnet)](https://www.nuget.org/packages/Repl.Telnet) | | | Shell completion — Bash, PowerShell, Zsh, Fish, Nushell | [![Repl.Core](https://img.shields.io/nuget/vpre/Repl.Core?logo=nuget&label=Repl.Core)](https://www.nuget.org/packages/Repl.Core) | | @@ -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 Metadata { get; } = []; + + public override bool IsMatch(string uri) => + string.Equals(uri, _registration.Uri, StringComparison.OrdinalIgnoreCase); + + public override async ValueTask ReadAsync( + RequestContext request, + CancellationToken cancellationToken = default) + { + var html = await McpAppResourceInvoker + .InvokeAsync( + _registration.Handler, + _services, + new McpAppResourceContext(request.Params.Uri), + request, + cancellationToken) + .ConfigureAwait(false); + + return new ReadResourceResult + { + Contents = + [ + new TextResourceContents + { + Uri = request.Params.Uri, + MimeType = McpAppValidation.ResourceMimeType, + Text = html, + Meta = McpAppMetadata.BuildResourceMeta(_registration.Options), + }, + ], + }; + } +} diff --git a/src/Repl.Mcp/McpAppResourceContext.cs b/src/Repl.Mcp/McpAppResourceContext.cs new file mode 100644 index 0000000..005cf50 --- /dev/null +++ b/src/Repl.Mcp/McpAppResourceContext.cs @@ -0,0 +1,7 @@ +namespace Repl.Mcp; + +/// +/// Request context passed to MCP App UI resource factories. +/// +/// The requested UI resource URI. +public sealed record McpAppResourceContext(string Uri); diff --git a/src/Repl.Mcp/McpAppResourceInvoker.cs b/src/Repl.Mcp/McpAppResourceInvoker.cs new file mode 100644 index 0000000..0e7fbb0 --- /dev/null +++ b/src/Repl.Mcp/McpAppResourceInvoker.cs @@ -0,0 +1,110 @@ +using System.Reflection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +internal static class McpAppResourceInvoker +{ + public static async ValueTask InvokeAsync( + Delegate handler, + IServiceProvider services, + McpAppResourceContext context, + RequestContext request, + CancellationToken cancellationToken) + { + var arguments = BindArguments(handler, services, context, request, cancellationToken); + object? result; + try + { + result = handler.DynamicInvoke(arguments); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + + return await ConvertResultAsync(result).ConfigureAwait(false); + } + + private static object?[] BindArguments( + Delegate handler, + IServiceProvider services, + McpAppResourceContext context, + RequestContext request, + CancellationToken cancellationToken) + { + var parameters = handler.Method.GetParameters(); + var arguments = new object?[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + arguments[i] = BindArgument(parameters[i], services, context, request, cancellationToken); + } + + return arguments; + } + + private static object? BindArgument( + ParameterInfo parameter, + IServiceProvider services, + McpAppResourceContext context, + RequestContext request, + CancellationToken cancellationToken) + { + if (parameter.ParameterType == typeof(McpAppResourceContext)) + { + return context; + } + + if (parameter.ParameterType == typeof(RequestContext)) + { + return request; + } + + if (parameter.ParameterType == typeof(CancellationToken)) + { + return cancellationToken; + } + + if (parameter.ParameterType == typeof(IServiceProvider)) + { + return services; + } + + var service = services.GetService(parameter.ParameterType); + if (service is not null) + { + return service; + } + + if (parameter.HasDefaultValue) + { + return parameter.DefaultValue; + } + + throw new InvalidOperationException( + $"Cannot resolve MCP App UI resource parameter '{parameter.Name}' of type '{parameter.ParameterType}'."); + } + + private static async ValueTask ConvertResultAsync(object? result) + { + switch (result) + { + case null: + return string.Empty; + case string text: + return text; + case ValueTask valueTask: + return await valueTask.ConfigureAwait(false); + case Task task: + return await task.ConfigureAwait(false); + case Task task: + await task.ConfigureAwait(false); + return string.Empty; + default: + return result.ToString() ?? string.Empty; + } + } +} diff --git a/src/Repl.Mcp/McpAppResourceOptions.cs b/src/Repl.Mcp/McpAppResourceOptions.cs new file mode 100644 index 0000000..d3510ea --- /dev/null +++ b/src/Repl.Mcp/McpAppResourceOptions.cs @@ -0,0 +1,56 @@ +namespace Repl.Mcp; + +/// +/// Rendering and security metadata for an MCP App UI resource. +/// +public sealed class McpAppResourceOptions +{ + /// + /// Human-readable resource name shown by hosts that list app resources. + /// + public string? Name { get; set; } + + /// + /// Optional description of the UI resource. + /// + public string? Description { get; set; } + + /// + /// Content Security Policy domains requested by the UI. + /// + public McpAppCsp? Csp { get; set; } + + /// + /// Browser permissions requested by the UI. + /// + public McpAppPermissions? Permissions { get; set; } + + /// + /// Optional host-specific dedicated origin for the UI. + /// + public string? Domain { get; set; } + + /// + /// Optional visual boundary preference. + /// + public bool? PrefersBorder { get; set; } + + /// + /// Optional preferred display mode requested by the UI. + /// Standard values are available from . + /// Hosts decide which display modes they support. + /// + public string? PreferredDisplayMode { get; set; } + + /// + /// Optional fallback text returned when an MCP client calls the launcher tool. + /// The HTML-producing handler is used only for the UI resource. + /// + public string? LauncherText { get; set; } + + /// + /// Additional host-specific _meta.ui fields. + /// Use this for experimental or host-specific presentation options. + /// + public IDictionary UiMetadata { get; } = new Dictionary(StringComparer.Ordinal); +} diff --git a/src/Repl.Mcp/McpAppResourceRegistration.cs b/src/Repl.Mcp/McpAppResourceRegistration.cs new file mode 100644 index 0000000..ea57b0f --- /dev/null +++ b/src/Repl.Mcp/McpAppResourceRegistration.cs @@ -0,0 +1,6 @@ +namespace Repl.Mcp; + +internal sealed record McpAppResourceRegistration( + string Uri, + Delegate Handler, + McpAppResourceOptions Options); diff --git a/src/Repl.Mcp/McpAppToolOptions.cs b/src/Repl.Mcp/McpAppToolOptions.cs new file mode 100644 index 0000000..c419512 --- /dev/null +++ b/src/Repl.Mcp/McpAppToolOptions.cs @@ -0,0 +1,13 @@ +namespace Repl.Mcp; + +/// +/// Metadata linking an MCP tool to an MCP App UI resource. +/// +/// The ui:// resource rendered for the tool. +public sealed record McpAppToolOptions(string ResourceUri) +{ + /// + /// Controls whether the linked tool is visible to the model, the app iframe, or both. + /// + public McpAppVisibility Visibility { get; init; } = McpAppVisibility.ModelAndApp; +} diff --git a/src/Repl.Mcp/McpAppValidation.cs b/src/Repl.Mcp/McpAppValidation.cs new file mode 100644 index 0000000..bd373b7 --- /dev/null +++ b/src/Repl.Mcp/McpAppValidation.cs @@ -0,0 +1,16 @@ +namespace Repl.Mcp; + +internal static class McpAppValidation +{ + public const string ResourceMimeType = "text/html;profile=mcp-app"; + private const string UiScheme = "ui://"; + + public static void ThrowIfInvalidUiUri(string uri) + { + ArgumentException.ThrowIfNullOrWhiteSpace(uri); + if (!uri.StartsWith(UiScheme, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("MCP App resource URIs must use the ui:// scheme.", nameof(uri)); + } + } +} diff --git a/src/Repl.Mcp/McpAppVisibility.cs b/src/Repl.Mcp/McpAppVisibility.cs new file mode 100644 index 0000000..a3da62b --- /dev/null +++ b/src/Repl.Mcp/McpAppVisibility.cs @@ -0,0 +1,17 @@ +namespace Repl.Mcp; + +/// +/// Controls whether an MCP App-linked tool is visible to the model, the app iframe, or both. +/// +[Flags] +public enum McpAppVisibility +{ + /// The tool is visible to the model. + Model = 1, + + /// The tool is visible to the app iframe. + App = 2, + + /// The tool is visible to both the model and the app iframe. + ModelAndApp = Model | App, +} diff --git a/src/Repl.Mcp/McpJsonContext.cs b/src/Repl.Mcp/McpJsonContext.cs index 50447e3..8b0700e 100644 --- a/src/Repl.Mcp/McpJsonContext.cs +++ b/src/Repl.Mcp/McpJsonContext.cs @@ -10,5 +10,6 @@ namespace Repl.Mcp; [JsonSerializable(typeof(JsonObject))] [JsonSerializable(typeof(Tool[]))] [JsonSerializable(typeof(string))] +[JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(bool))] internal sealed partial class McpJsonContext : JsonSerializerContext; diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 2773f7d..0cdd6c7 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -523,12 +523,45 @@ private void UnsubscribeFromRoutingChanges() } } - private static ServerCapabilities BuildCapabilities() => new() + private ServerCapabilities BuildCapabilities() { - Tools = new ToolsCapability { ListChanged = true }, - Resources = new ResourcesCapability { ListChanged = true }, - Prompts = new PromptsCapability { ListChanged = true }, - }; + var capabilities = new ServerCapabilities + { + Tools = new ToolsCapability { ListChanged = true }, + Resources = new ResourcesCapability { ListChanged = true }, + Prompts = new PromptsCapability { ListChanged = true }, + }; + + if (_options.EnableApps || HasMcpAppResources()) + { +#pragma warning disable MCPEXP001 + capabilities.Extensions = new Dictionary(StringComparer.Ordinal) + { + [McpAppMetadata.ExtensionName] = new JsonObject + { + ["mimeTypes"] = JsonSerializer.SerializeToNode( + new[] { McpAppValidation.ResourceMimeType }, + McpJsonContext.Default.StringArray), + }, + }; +#pragma warning restore MCPEXP001 + } + + return capabilities; + } + + private bool HasMcpAppResources() + { + if (_options.UiResources.Count > 0) + { + return true; + } + + var model = CreateDocumentationModel(); + return model.Commands.Any(static command => + command.Metadata?.ContainsKey(McpAppMetadata.ResourceMetadataKey) == true + || command.Metadata?.ContainsKey(McpAppMetadata.CommandMetadataKey) == true); + } private static Tool CreateCompatibilityDiscoverTool() => new() { @@ -673,6 +706,12 @@ private List GenerateAllTools( continue; } + if (TryGetAppResourceOptions(command, out var appResourceOptions)) + { + AddMcpAppLauncherTool(command, appResourceOptions, tools, nameSet, adapter, separator); + continue; + } + AddTool(command, tools, nameSet, adapter, separator); } @@ -708,13 +747,48 @@ private static void AddTool( Dictionary nameSet, McpToolAdapter adapter, char separator) + { + var toolName = TryReserveToolName(command, nameSet, separator); + if (toolName is null) + { + return; + } + + adapter.RegisterRoute(toolName, command); + tools.Add(new ReplMcpServerTool(command, toolName, adapter)); + } + + private static void AddMcpAppLauncherTool( + ReplDocCommand command, + McpAppCommandResourceOptions appResourceOptions, + List tools, + Dictionary nameSet, + McpToolAdapter adapter, + char separator) + { + var toolName = TryReserveToolName(command, nameSet, separator); + if (toolName is null) + { + return; + } + + adapter.RegisterStaticResult( + toolName, + ReplMcpAppLauncherTool.BuildFallbackTextCore(command, appResourceOptions)); + tools.Add(new ReplMcpAppLauncherTool(command, toolName, appResourceOptions)); + } + + private static string? TryReserveToolName( + ReplDocCommand command, + Dictionary nameSet, + char separator) { var toolName = McpToolNameFlattener.Flatten(command.Path, separator); if (nameSet.TryGetValue(toolName, out var existingPath)) { if (string.Equals(command.Path, existingPath, StringComparison.OrdinalIgnoreCase)) { - return; + return null; } throw new InvalidOperationException( @@ -723,8 +797,7 @@ private static void AddTool( } nameSet[toolName] = command.Path; - adapter.RegisterRoute(toolName, command); - tools.Add(new ReplMcpServerTool(command, toolName, adapter)); + return toolName; } // ── Resource generation ──────────────────────────────────────────── @@ -755,6 +828,18 @@ private List GenerateResources( } var resourceName = McpToolNameFlattener.Flatten(resource.Path, separator); + if (TryGetAppResourceOptions(docCommand, out var appResourceOptions)) + { + var mcpAppResource = new ReplMcpServerUiResource( + docCommand!, + resourceName, + appResourceOptions, + adapter); + adapter.RegisterRoute(resourceName, docCommand!); + resources.Add(mcpAppResource); + continue; + } + var uriTemplate = McpToolNameFlattener.BuildResourceUri(resource.Path, _options.ResourceUriScheme); var mcpResource = new ReplMcpServerResource(resource, resourceName, uriTemplate, adapter); @@ -766,9 +851,30 @@ private List GenerateResources( resources.Add(mcpResource); } + foreach (var uiResource in _options.UiResources) + { + resources.Add(new McpAppResource(uiResource, _sessionServices)); + } + return resources; } + private static bool TryGetAppResourceOptions( + ReplDocCommand? command, + [NotNullWhen(true)] out McpAppCommandResourceOptions? options) + { + if (command?.Metadata is not null + && command.Metadata.TryGetValue(McpAppMetadata.ResourceMetadataKey, out var value) + && value is McpAppCommandResourceOptions appResourceOptions) + { + options = appResourceOptions; + return true; + } + + options = null; + return false; + } + // ── Prompt generation ────────────────────────────────────────────── private List CollectPrompts( diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 6589cf7..0a04121 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -18,6 +18,7 @@ internal sealed partial class McpToolAdapter private readonly ReplMcpServerOptions _options; private readonly IServiceProvider _services; private readonly System.Collections.Concurrent.ConcurrentDictionary _toolRoutes = new(StringComparer.OrdinalIgnoreCase); + private readonly System.Collections.Concurrent.ConcurrentDictionary _staticToolResults = new(StringComparer.OrdinalIgnoreCase); public McpToolAdapter(ICoreReplApp app, ReplMcpServerOptions options, IServiceProvider services) { @@ -29,7 +30,11 @@ public McpToolAdapter(ICoreReplApp app, ReplMcpServerOptions options, IServicePr /// /// Clears all registered routes. Called before rebuilding on routing invalidation. /// - public void ClearRoutes() => _toolRoutes.Clear(); + public void ClearRoutes() + { + _toolRoutes.Clear(); + _staticToolResults.Clear(); + } /// /// Atomically replaces all routes from another adapter instance. @@ -38,10 +43,16 @@ public McpToolAdapter(ICoreReplApp app, ReplMcpServerOptions options, IServicePr public void ReplaceRoutes(McpToolAdapter source) { _toolRoutes.Clear(); + _staticToolResults.Clear(); foreach (var (key, value) in source._toolRoutes) { _toolRoutes[key] = value; } + + foreach (var (key, value) in source._staticToolResults) + { + _staticToolResults[key] = value; + } } /// @@ -52,6 +63,14 @@ public void RegisterRoute(string toolName, ReplDocCommand command) _toolRoutes[toolName] = command; } + /// + /// Registers a static text result for launcher-style tools. + /// + public void RegisterStaticResult(string toolName, string text) + { + _staticToolResults[toolName] = text; + } + /// /// Invokes a Repl command through the pipeline for an MCP tool call. /// @@ -60,8 +79,17 @@ public async Task InvokeAsync( IDictionary arguments, McpServer? server, ProgressToken? progressToken, - CancellationToken ct) + CancellationToken ct, + bool allowStaticResults = true) { + if (allowStaticResults && _staticToolResults.TryGetValue(toolName, out var staticResult)) + { + return new CallToolResult + { + Content = [new TextContentBlock { Text = staticResult }], + }; + } + if (!_toolRoutes.TryGetValue(toolName, out var command)) { return ErrorResult($"Unknown tool: {toolName}"); @@ -203,6 +231,6 @@ internal static List ReconstructTokens( IsError = true, }; - [GeneratedRegex(@"^\{(?\w+)(?::\w+)?\}$", RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] + [GeneratedRegex(@"^\{(?[^:{}?]+)(?:\?)?(?::[^{}:]+)?\}$", RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] private static partial Regex DynamicSegmentPattern(); } diff --git a/src/Repl.Mcp/McpToolNameFlattener.cs b/src/Repl.Mcp/McpToolNameFlattener.cs index 86a1dcf..5d03cfd 100644 --- a/src/Repl.Mcp/McpToolNameFlattener.cs +++ b/src/Repl.Mcp/McpToolNameFlattener.cs @@ -50,8 +50,8 @@ public static string BuildResourceUri(string routePath, string scheme = "repl") var match = DynamicSegmentPattern().Match(segment); if (match.Success) { - // Strip the constraint: {name:constraint} → {name} - var name = segment.TrimStart('{').TrimEnd('}').Split(':')[0]; + // Strip optional markers and constraints: {name?:constraint} -> {name} + var name = match.Groups["name"].Value; parts.Add($"{{{name}}}"); } else @@ -74,6 +74,6 @@ public static string BuildResourceUri(string routePath, string scheme = "repl") _ => '_', }; - [GeneratedRegex(@"^\{\w+(?::\w+)?\}$", RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] + [GeneratedRegex(@"^\{(?[^:{}?]+)(?:\?)?(?::[^{}:]+)?\}$", RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] private static partial Regex DynamicSegmentPattern(); } diff --git a/src/Repl.Mcp/README.md b/src/Repl.Mcp/README.md index 901411b..21a67a4 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -1,6 +1,6 @@ # Repl.Mcp -MCP server integration for [Repl Toolkit](https://github.com/yllibed/repl) — expose your command graph as AI agent tools via the [Model Context Protocol](https://modelcontextprotocol.io). +MCP server integration for [Repl Toolkit](https://github.com/yllibed/repl) — expose your command graph as AI agent tools, resources, prompts, and MCP Apps UI via the [Model Context Protocol](https://modelcontextprotocol.io). ## One line to add @@ -17,6 +17,23 @@ myapp mcp serve # AI agents connect here myapp # still a CLI / interactive REPL ``` +## MCP Apps + +Repl.Mcp can also expose MCP Apps UI resources: + +This support is experimental in the current version. `AsMcpAppResource()` handlers should return generated HTML as `string`, `Task`, or `ValueTask`; richer return shapes and asset helpers may be added later. + +```csharp +app.Map("contacts dashboard", (ContactStore contacts) => + $"{contacts.All.Count} contacts") + .WithDescription("Open the contacts dashboard") + .AsMcpAppResource() + .WithMcpAppBorder() + .WithMcpAppDisplayMode(McpAppDisplayModes.Fullscreen); +``` + +Clients with MCP Apps support render the generated `ui://` resource. Other MCP clients still receive the command's normal launcher text instead of raw HTML. + ## What agents see | You write | Agents get | @@ -24,6 +41,9 @@ myapp # still a CLI / interactive REPL | `.ReadOnly()` | `readOnlyHint` — call autonomously | | `.Destructive()` | `destructiveHint` — ask for confirmation | | `.AsResource()` | MCP resource with `repl://` URI | +| `.AsMcpAppResource()` | MCP Apps HTML resource with `ui://` URI | +| `.WithMcpAppBorder()` | MCP Apps border/background preference | +| `.WithMcpAppDisplayMode(McpAppDisplayModes.Fullscreen)` | MCP Apps display preference | | `.AsPrompt()` | MCP prompt template | | `.AutomationHidden()` | Not visible to agents | | `{id:guid}` | `{ "type": "string", "format": "uuid" }` | @@ -33,7 +53,9 @@ myapp # still a CLI / interactive REPL Claude Desktop, Claude Code, VS Code Copilot, Cursor, and any MCP-compatible agent. +MCP Apps host support varies. VS Code currently renders MCP Apps inline; hosts that support display mode requests can honor `preferredDisplayMode`. + ## Learn more - [Full documentation](https://github.com/yllibed/repl/blob/main/docs/mcp-server.md) — annotations, interaction degradation, client compatibility matrix, agent configuration, NuGet publishing -- [Sample app](https://github.com/yllibed/repl/tree/main/samples/08-mcp-server) — resources, tools, prompts, and annotations in action +- [Sample app](https://github.com/yllibed/repl/tree/main/samples/08-mcp-server) — resources, tools, prompts, annotations, and a minimal MCP Apps UI in action diff --git a/src/Repl.Mcp/ReplMcpAppLauncherTool.cs b/src/Repl.Mcp/ReplMcpAppLauncherTool.cs new file mode 100644 index 0000000..4cd925e --- /dev/null +++ b/src/Repl.Mcp/ReplMcpAppLauncherTool.cs @@ -0,0 +1,63 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Repl.Documentation; + +namespace Repl.Mcp; + +internal sealed class ReplMcpAppLauncherTool : McpServerTool +{ + private readonly Tool _protocolTool; + private readonly string _fallbackText; + + public ReplMcpAppLauncherTool( + ReplDocCommand command, + string toolName, + McpAppCommandResourceOptions options) + { + _fallbackText = BuildFallbackText(command, options); + _protocolTool = new Tool + { + Name = toolName, + Description = McpSchemaGenerator.BuildDescription(command), + InputSchema = McpSchemaGenerator.BuildInputSchema(command), + Annotations = McpSchemaGenerator.MapAnnotations(command.Annotations), + Meta = McpAppMetadata.BuildToolMeta( + new McpAppToolOptions(options.ResourceUri) { Visibility = options.Visibility }), + }; + } + + public override Tool ProtocolTool => _protocolTool; + + public override IReadOnlyList Metadata { get; } = []; + + public override ValueTask InvokeAsync( + RequestContext request, + CancellationToken cancellationToken = default) => + ValueTask.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Text = _fallbackText }], + }); + + private static string BuildFallbackText( + ReplDocCommand command, + McpAppCommandResourceOptions options) => + BuildFallbackTextCore(command, options); + + internal static string BuildFallbackTextCore( + ReplDocCommand command, + McpAppCommandResourceOptions options) + { + if (!string.IsNullOrWhiteSpace(options.ResourceOptions.LauncherText)) + { + return options.ResourceOptions.LauncherText; + } + + if (!string.IsNullOrWhiteSpace(command.Description)) + { + return command.Description; + } + + var name = options.ResourceOptions.Name ?? command.Path; + return $"Opening {name}."; + } +} diff --git a/src/Repl.Mcp/ReplMcpServerOptions.cs b/src/Repl.Mcp/ReplMcpServerOptions.cs index e45dcaa..5d033ba 100644 --- a/src/Repl.Mcp/ReplMcpServerOptions.cs +++ b/src/Repl.Mcp/ReplMcpServerOptions.cs @@ -12,6 +12,13 @@ namespace Repl.Mcp; /// public sealed class ReplMcpServerOptions { + /// + /// When true, the server advertises the MCP Apps UI extension. + /// Mapped MCP App resources and registrations + /// enable this automatically. + /// + public bool EnableApps { get; set; } + /// /// Server name reported in the MCP initialize response. /// Defaults to the assembly product name. @@ -86,6 +93,7 @@ public sealed class ReplMcpServerOptions public DynamicToolCompatibilityMode DynamicToolCompatibility { get; set; } = DynamicToolCompatibilityMode.Disabled; private readonly List _prompts = []; + private readonly List _uiResources = []; /// /// Registers an MCP prompt with a DI-injectable handler. @@ -101,8 +109,158 @@ public ReplMcpServerOptions Prompt(string name, Delegate handler) return this; } + /// + /// Registers a static MCP App HTML resource. + /// + /// The ui:// resource URI. + /// Complete HTML document returned for the resource. + /// Optional resource metadata configuration. + /// The same options instance. + public ReplMcpServerOptions UiResource( + string uri, + string html, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(html); + return UiResource(uri, () => html, configure); + } + + /// + /// Registers an MCP App HTML resource. + /// + /// The ui:// resource URI. + /// Factory returning a complete HTML document. Parameters are resolved from services or the MCP App resource context. + /// Optional resource metadata configuration. + /// The same options instance. + public ReplMcpServerOptions UiResource( + string uri, + Func htmlFactory, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(htmlFactory); + return UiResource( + uri, + (_, _) => ValueTask.FromResult(htmlFactory()), + configure); + } + + /// + /// Registers an MCP App HTML resource. + /// + /// The ui:// resource URI. + /// Factory returning a complete HTML document. Parameters are resolved from services or the MCP App resource context. + /// Optional resource metadata configuration. + /// The same options instance. + public ReplMcpServerOptions UiResource( + string uri, + Func> htmlFactory, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(htmlFactory); + return UiResource( + uri, + (_, cancellationToken) => htmlFactory(cancellationToken), + configure); + } + + /// + /// Registers an MCP App HTML resource. + /// + /// The ui:// resource URI. + /// Factory returning a complete HTML document. Parameters are resolved from services or the MCP App resource context. + /// Optional resource metadata configuration. + /// The same options instance. + public ReplMcpServerOptions UiResource( + string uri, + Func> htmlFactory, + Action? configure = null) + { + McpAppValidation.ThrowIfInvalidUiUri(uri); + return UiResource(uri, (Delegate)htmlFactory, configure); + } + + /// + /// Registers an MCP App HTML resource with a single DI-injected service parameter. + /// + /// Service type resolved from the Repl app service provider. + /// The ui:// resource URI. + /// Factory returning a complete HTML document. + /// Optional resource metadata configuration. + /// The same options instance. + public ReplMcpServerOptions UiResource( + string uri, + Func htmlFactory, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(htmlFactory); + return UiResource(uri, (Delegate)htmlFactory, configure); + } + + /// + /// Registers an MCP App HTML resource with a single DI-injected service parameter. + /// + /// Service type resolved from the Repl app service provider. + /// The ui:// resource URI. + /// Factory returning a complete HTML document. + /// Optional resource metadata configuration. + /// The same options instance. + public ReplMcpServerOptions UiResource( + string uri, + Func> htmlFactory, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(htmlFactory); + return UiResource(uri, (Delegate)htmlFactory, configure); + } + + /// + /// Registers an MCP App HTML resource with a single DI-injected service parameter. + /// + /// Service type resolved from the Repl app service provider. + /// The ui:// resource URI. + /// Factory returning a complete HTML document. + /// Optional resource metadata configuration. + /// The same options instance. + public ReplMcpServerOptions UiResource( + string uri, + Func> htmlFactory, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(htmlFactory); + return UiResource(uri, (Delegate)htmlFactory, configure); + } + + /// + /// Registers an MCP App HTML resource with a DI-injectable handler. + /// + /// The ui:// resource URI. + /// Handler returning a complete HTML document. Parameters are resolved from services or the MCP App resource context. + /// Optional resource metadata configuration. + /// The same options instance. + public ReplMcpServerOptions UiResource( + string uri, + Delegate handler, + Action? configure = null) + { + McpAppValidation.ThrowIfInvalidUiUri(uri); + ArgumentNullException.ThrowIfNull(handler); + + var options = new McpAppResourceOptions(); + configure?.Invoke(options); + options.Name ??= uri; + + EnableApps = true; + _uiResources.Add(new McpAppResourceRegistration(uri, handler, options)); + return this; + } + /// /// Gets the registered prompt definitions. /// internal IReadOnlyList Prompts => _prompts; + + /// + /// Gets the registered MCP App UI resources. + /// + internal IReadOnlyList UiResources => _uiResources; } diff --git a/src/Repl.Mcp/ReplMcpServerResource.cs b/src/Repl.Mcp/ReplMcpServerResource.cs index da0b7a9..5421769 100644 --- a/src/Repl.Mcp/ReplMcpServerResource.cs +++ b/src/Repl.Mcp/ReplMcpServerResource.cs @@ -67,7 +67,12 @@ public override async ValueTask ReadAsync( var arguments = ExtractArguments(request.Params.Uri); var result = await _adapter.InvokeAsync( - _resourceName, arguments, request.Server, progressToken: null, cancellationToken) + _resourceName, + arguments, + request.Server, + progressToken: null, + cancellationToken, + allowStaticResults: false) .ConfigureAwait(false); if (result.IsError == true) diff --git a/src/Repl.Mcp/ReplMcpServerTool.cs b/src/Repl.Mcp/ReplMcpServerTool.cs index ae9ec44..6267560 100644 --- a/src/Repl.Mcp/ReplMcpServerTool.cs +++ b/src/Repl.Mcp/ReplMcpServerTool.cs @@ -33,6 +33,9 @@ public ReplMcpServerTool( Execution = command.Annotations?.LongRunning == true ? new ToolExecution { TaskSupport = ToolTaskSupport.Optional } : null, + Meta = TryGetAppOptions(command, out var appOptions) + ? McpAppMetadata.BuildToolMeta(appOptions) + : null, }; } #pragma warning restore MCPEXP001 @@ -56,4 +59,18 @@ public override async ValueTask InvokeAsync( _protocolTool.Name, arguments, request.Server, progressToken, cancellationToken) .ConfigureAwait(false); } + + private static bool TryGetAppOptions(ReplDocCommand command, out McpAppToolOptions options) + { + if (command.Metadata is not null + && command.Metadata.TryGetValue(McpAppMetadata.CommandMetadataKey, out var value) + && value is McpAppToolOptions appOptions) + { + options = appOptions; + return true; + } + + options = null!; + return false; + } } diff --git a/src/Repl.Mcp/ReplMcpServerUiResource.cs b/src/Repl.Mcp/ReplMcpServerUiResource.cs new file mode 100644 index 0000000..8094795 --- /dev/null +++ b/src/Repl.Mcp/ReplMcpServerUiResource.cs @@ -0,0 +1,187 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Repl.Documentation; + +namespace Repl.Mcp; + +internal sealed partial class ReplMcpServerUiResource : McpServerResource +{ + private readonly string _resourceName; + private readonly McpToolAdapter _adapter; + private readonly McpAppCommandResourceOptions _options; + private readonly ResourceTemplate _protocolResourceTemplate; + private readonly Regex? _uriParser; + private readonly string[] _variableNames; + + public ReplMcpServerUiResource( + ReplDocCommand command, + string resourceName, + McpAppCommandResourceOptions options, + McpToolAdapter adapter) + { + _resourceName = resourceName; + _adapter = adapter; + _options = options; + _protocolResourceTemplate = new ResourceTemplate + { + Name = options.ResourceOptions.Name ?? BuildDefaultResourceName(command.Path), + Description = options.ResourceOptions.Description ?? command.Description, + UriTemplate = options.ResourceUri, + MimeType = McpAppValidation.ResourceMimeType, + Meta = McpAppMetadata.BuildResourceMeta(options.ResourceOptions), + }; + + _variableNames = BuildUriParser(options.ResourceUri, out _uriParser); + } + + public override ResourceTemplate ProtocolResourceTemplate => _protocolResourceTemplate; + + public override IReadOnlyList Metadata { get; } = []; + + public override bool IsMatch(string uri) + { + ArgumentNullException.ThrowIfNull(uri); + + if (_uriParser is not null) + { + return _uriParser.IsMatch(uri); + } + + return string.Equals(uri, _options.ResourceUri, StringComparison.OrdinalIgnoreCase); + } + + public override async ValueTask ReadAsync( + RequestContext request, + CancellationToken cancellationToken = default) + { + var arguments = ExtractArguments(request.Params.Uri); + + var result = await _adapter.InvokeAsync( + _resourceName, + arguments, + request.Server, + progressToken: null, + cancellationToken, + allowStaticResults: false) + .ConfigureAwait(false); + + if (result.IsError == true) + { + var errorText = result.Content?.OfType().FirstOrDefault()?.Text + ?? "UI resource read failed."; + throw new McpException(errorText); + } + + var text = result.Content?.OfType().FirstOrDefault()?.Text ?? ""; + return new ReadResourceResult + { + Contents = + [ + new TextResourceContents + { + Uri = request.Params.Uri, + MimeType = McpAppValidation.ResourceMimeType, + Text = UnwrapJsonString(text), + Meta = McpAppMetadata.BuildResourceMeta(_options.ResourceOptions), + }, + ], + }; + } + + private Dictionary ExtractArguments(string uri) + { + var arguments = new Dictionary(StringComparer.Ordinal); + + if (_uriParser is null) + { + return arguments; + } + + var match = _uriParser.Match(uri); + if (!match.Success) + { + return arguments; + } + + foreach (var pair in _variableNames + .Select(name => (Name: name, Group: match.Groups[name])) + .Where(pair => pair.Group.Success)) + { + var value = Uri.UnescapeDataString(pair.Group.Value); + arguments[pair.Name] = JsonSerializer.SerializeToElement(value, McpJsonContext.Default.String); + } + + return arguments; + } + + private static string[] BuildUriParser(string uriTemplate, out Regex? parser) + { + var variableNames = new List(); + var regexParts = new System.Text.StringBuilder("^"); + + var remaining = uriTemplate.AsSpan(); + while (remaining.Length > 0) + { + var braceIndex = remaining.IndexOf('{'); + if (braceIndex < 0) + { + regexParts.Append(Regex.Escape(remaining.ToString())); + break; + } + + if (braceIndex > 0) + { + regexParts.Append(Regex.Escape(remaining[..braceIndex].ToString())); + } + + var closeIndex = remaining.IndexOf('}'); + var name = remaining[(braceIndex + 1)..closeIndex].ToString(); + variableNames.Add(name); + regexParts.Append($"(?<{name}>[^/]+)"); + remaining = remaining[(closeIndex + 1)..]; + } + + regexParts.Append('$'); + + if (variableNames.Count == 0) + { + parser = null; + return []; + } + + parser = new Regex( + regexParts.ToString(), + RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture, + TimeSpan.FromSeconds(1)); + return [.. variableNames]; + } + + private static string UnwrapJsonString(string text) + { + if (text.Length == 0 || text[0] != '"') + { + return text; + } + + try + { + return JsonSerializer.Deserialize(text, McpJsonContext.Default.String) ?? text; + } + catch (JsonException) + { + return text; + } + } + + private static string BuildDefaultResourceName(string path) + { + var parts = path + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(static segment => segment.Length == 0 || segment[0] != '{') + .ToArray(); + return parts.Length == 0 ? path : string.Join(' ', parts); + } +} diff --git a/src/Repl.McpTests/Given_McpApps.cs b/src/Repl.McpTests/Given_McpApps.cs new file mode 100644 index 0000000..d1cb35c --- /dev/null +++ b/src/Repl.McpTests/Given_McpApps.cs @@ -0,0 +1,330 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using Repl.Mcp; + +namespace Repl.McpTests; + +[TestClass] +public sealed class Given_McpApps +{ + [TestMethod] + [Description("EnableApps advertises the MCP Apps UI extension capability.")] + public void When_AppsEnabled_Then_ServerCapabilitiesIncludeUiExtension() + { + var app = ReplApp.Create(); + app.Map("dashboard", () => "open dashboard").ReadOnly(); + + var options = app.BuildMcpServerOptions(o => o.EnableApps = true); + +#pragma warning disable MCPEXP001 + options.Capabilities!.Extensions.Should().ContainKey(McpAppMetadata.ExtensionName); +#pragma warning restore MCPEXP001 + } + + [TestMethod] + [Description("WithMcpApp adds UI metadata to the MCP tool declaration.")] + public void When_CommandHasMcpApp_Then_ToolContainsUiMetadata() + { + var app = ReplApp.Create(); + app.Map("dashboard", () => "open dashboard") + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard", McpAppVisibility.ModelAndApp); + + var options = app.BuildMcpServerOptions(o => o.EnableApps = true); + var tool = options.ToolCollection!.Single(tool => + string.Equals(tool.ProtocolTool.Name, "dashboard", StringComparison.Ordinal)); + var ui = tool.ProtocolTool.Meta!["ui"]!.AsObject(); + + ui["resourceUri"]!.GetValue().Should().Be("ui://contacts/dashboard"); + ui["visibility"]!.AsArray().Select(static node => node!.GetValue()) + .Should().BeEquivalentTo(["model", "app"]); + } + + [TestMethod] + [Description("UiResource returns an MCP App HTML resource with CSP metadata.")] + public async Task When_UiResourceRead_Then_ReturnsHtmlWithMcpAppMimeType() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => + { + app.Map("dashboard", () => "open dashboard") + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard"); + }, + options => options.UiResource( + "ui://contacts/dashboard", + "Dashboard", + resource => + { + resource.Name = "Contacts Dashboard"; + resource.Description = "Interactive contacts dashboard"; + resource.Csp = new McpAppCsp + { + ConnectDomains = ["https://api.example.com"], + ResourceDomains = ["https://cdn.example.com"], + }; + resource.PrefersBorder = true; + })).ConfigureAwait(false); + + var result = await fixture.Client.ReadResourceAsync("ui://contacts/dashboard").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + var ui = content.Meta!["ui"]!.AsObject(); + var csp = ui["csp"]!.AsObject(); + + content.MimeType.Should().Be(McpAppValidation.ResourceMimeType); + content.Text.Should().Contain("Dashboard"); + ui["prefersBorder"]!.GetValue().Should().BeTrue(); + csp["connectDomains"]!.AsArray().Select(static node => node!.GetValue()) + .Should().ContainSingle("https://api.example.com"); + csp["resourceDomains"]!.AsArray().Select(static node => node!.GetValue()) + .Should().ContainSingle("https://cdn.example.com"); + } + + [TestMethod] + [Description("Apps metadata does not change regular tool fallback output.")] + public async Task When_AppToolCalled_Then_TextFallbackStillWorks() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => + { + app.Map("dashboard", () => "Open the contacts dashboard.") + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard"); + }, + options => options.UiResource( + "ui://contacts/dashboard", + "Dashboard")).ConfigureAwait(false); + + var result = await fixture.Client.CallToolAsync("dashboard").ConfigureAwait(false); + + result.IsError.Should().NotBeTrue(); + result.Content.OfType().Single().Text + .Should().Contain("Open the contacts dashboard."); + } + + [TestMethod] + [Description("AsMcpAppResource maps a DI-backed command as an MCP App HTML resource.")] + public async Task When_CommandIsMcpAppResource_Then_ResourceReadUsesInjectedServices() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => + { + app.Map("contacts dashboard", (DashboardService service) => + $"{service.Title}") + .WithDescription("Open dashboard") + .AsMcpAppResource() + .WithMcpAppBorder(); + }, + configureServices: services => + { + services.AddSingleton(new DashboardService("Injected contacts")); + }).ConfigureAwait(false); + + var result = await fixture.Client.ReadResourceAsync("ui://contacts/dashboard").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + var ui = content.Meta!["ui"]!.AsObject(); + + content.MimeType.Should().Be(McpAppValidation.ResourceMimeType); + content.Text.Should().Contain("Injected contacts"); + ui["prefersBorder"]!.GetValue().Should().BeTrue(); + } + + [TestMethod] + [Description("AsMcpAppResource can mark an HTML-producing command as app-only.")] + public void When_CommandIsAppOnlyMcpAppResource_Then_ToolVisibilityIsApp() + { + var app = ReplApp.Create(); + app.Map("contacts dashboard", () => "Contacts") + .AsMcpAppResource(visibility: McpAppVisibility.App); + + var options = app.BuildMcpServerOptions(); + var tool = options.ToolCollection!.Single(tool => + string.Equals(tool.ProtocolTool.Name, "contacts_dashboard", StringComparison.Ordinal)); + var ui = tool.ProtocolTool.Meta!["ui"]!.AsObject(); + + ui["resourceUri"]!.GetValue().Should().Be("ui://contacts/dashboard"); + ui["visibility"]!.AsArray().Select(static node => node!.GetValue()) + .Should().ContainSingle("app"); + } + + [TestMethod] + [Description("WithMcpAppDisplayMode can add preferred display metadata for hosts that support it.")] + public async Task When_CommandHasPreferredDisplayMode_Then_ResourceMetaContainsDisplayPreference() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("contacts dashboard", () => "Contacts") + .AsMcpAppResource(visibility: McpAppVisibility.App) + .WithMcpAppDisplayMode(McpAppDisplayModes.Fullscreen); + }).ConfigureAwait(false); + + var result = await fixture.Client.ReadResourceAsync("ui://contacts/dashboard").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + var ui = content.Meta!["ui"]!.AsObject(); + + ui["preferredDisplayMode"]!.GetValue().Should().Be(McpAppDisplayModes.Fullscreen); + } + + [TestMethod] + [Description("WithMcpAppUiMetadata can include host-specific UI metadata.")] + public async Task When_CommandHasCustomUiMetadata_Then_ResourceMetaIncludesIt() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("contacts dashboard", () => "Contacts") + .AsMcpAppResource() + .WithMcpAppUiMetadata("presentation", "flyout"); + }).ConfigureAwait(false); + + var result = await fixture.Client.ReadResourceAsync("ui://contacts/dashboard").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + var ui = content.Meta!["ui"]!.AsObject(); + + ui["presentation"]!.GetValue().Should().Be("flyout"); + } + + [TestMethod] + [Description("WithMcpAppPermissions can include browser permission metadata.")] + public async Task When_CommandHasPermissions_Then_ResourceMetaIncludesThem() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("contacts dashboard", () => "Contacts") + .AsMcpAppResource() + .WithMcpAppPermissions(new McpAppPermissions { ClipboardWrite = true }); + }).ConfigureAwait(false); + + var result = await fixture.Client.ReadResourceAsync("ui://contacts/dashboard").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + var ui = content.Meta!["ui"]!.AsObject(); + var permissions = ui["permissions"]!.AsObject(); + + permissions["clipboardWrite"]!.AsObject().Should().BeEmpty(); + } + + [TestMethod] + [Description("AsMcpAppResource exposes a launcher tool that does not return raw HTML.")] + public async Task When_McpAppResourceToolIsCalled_Then_ModelToolDoesNotReturnHtml() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("contacts dashboard", () => "Contacts") + .WithDescription("Open the contacts dashboard") + .AsMcpAppResource(); + }).ConfigureAwait(false); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + var launcher = tools.Single(tool => + string.Equals(tool.Name, "contacts_dashboard", StringComparison.Ordinal)); + var launcherUi = launcher.ProtocolTool.Meta!["ui"]!.AsObject(); + + launcherUi["visibility"]!.AsArray().Select(static node => node!.GetValue()) + .Should().BeEquivalentTo(["model", "app"]); + + var toolResult = await fixture.Client.CallToolAsync("contacts_dashboard").ConfigureAwait(false); + toolResult.Content.OfType().Single().Text + .Should().Contain("Open the contacts dashboard") + .And.NotContain("().Single().Text + .Should().Contain("Contacts"); + } + + [TestMethod] + [Description("WithMcpAppLauncherText customizes the launcher tool fallback text.")] + public async Task When_McpAppLauncherTextIsConfigured_Then_ToolReturnsThatText() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("contacts dashboard", () => "Contacts") + .AsMcpAppResource() + .WithMcpAppLauncherText("Opening the dashboard."); + }).ConfigureAwait(false); + + var toolResult = await fixture.Client.CallToolAsync("contacts_dashboard").ConfigureAwait(false); + + toolResult.Content.OfType().Single().Text + .Should().Be("Opening the dashboard."); + } + + [TestMethod] + [Description("AsMcpAppResource generates ui:// URI templates from route paths.")] + public async Task When_CommandIsParameterizedMcpAppResource_Then_UiUriTemplateBindsRouteArguments() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("contact {id:int} panel", (int id) => + $"Contact {id}") + .WithDescription("Open contact panel") + .AsMcpAppResource(); + }).ConfigureAwait(false); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + var tool = tools.Single(tool => + string.Equals(tool.Name, "contact_panel", StringComparison.Ordinal)); + var ui = tool.ProtocolTool.Meta!["ui"]!.AsObject(); + var result = await fixture.Client.ReadResourceAsync("ui://contact/42/panel").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + + ui["resourceUri"]!.GetValue().Should().Be("ui://contact/{id}/panel"); + content.Text.Should().Contain("Contact 42"); + } + + [TestMethod] + [Description("AsMcpAppResource includes nested context paths when it generates ui:// URI templates.")] + public async Task When_CommandIsNestedMcpAppResource_Then_UiUriTemplateIncludesContexts() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Context("viewer", viewer => + { + viewer.Context("session {id:int}", session => + { + session.Map("attach", (int id) => + $"Session {id}") + .AsMcpAppResource(); + }); + }); + }).ConfigureAwait(false); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + var tool = tools.Single(tool => + string.Equals(tool.Name, "viewer_session_attach", StringComparison.Ordinal)); + var ui = tool.ProtocolTool.Meta!["ui"]!.AsObject(); + var result = await fixture.Client.ReadResourceAsync("ui://viewer/session/42/attach").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + + ui["resourceUri"]!.GetValue().Should().Be("ui://viewer/session/{id}/attach"); + content.Text.Should().Contain("Session 42"); + } + + [TestMethod] + [Description("AsMcpAppResource supports custom route constraints when it generates ui:// URI templates.")] + public async Task When_CommandUsesCustomConstraint_Then_UiUriTemplateBindsRouteArgument() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Options(options => options.Parsing.AddRouteConstraint( + "tenant-slug", + static value => value.All(static character => char.IsAsciiLetterOrDigit(character) || character == '-'))); + + app.Map("tenant {slug:tenant-slug} panel", (string slug) => + $"Tenant {slug}") + .AsMcpAppResource(); + }).ConfigureAwait(false); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + var tool = tools.Single(tool => + string.Equals(tool.Name, "tenant_panel", StringComparison.Ordinal)); + var ui = tool.ProtocolTool.Meta!["ui"]!.AsObject(); + var result = await fixture.Client.ReadResourceAsync("ui://tenant/acme-prod/panel").ConfigureAwait(false); + var content = result.Contents.OfType().Single(); + + ui["resourceUri"]!.GetValue().Should().Be("ui://tenant/{slug}/panel"); + content.Text.Should().Contain("Tenant acme-prod"); + } + + private sealed record DashboardService(string Title); +} diff --git a/src/Repl.McpTests/Given_McpToolNameFlattener.cs b/src/Repl.McpTests/Given_McpToolNameFlattener.cs index 9f5c822..b4ed25a 100644 --- a/src/Repl.McpTests/Given_McpToolNameFlattener.cs +++ b/src/Repl.McpTests/Given_McpToolNameFlattener.cs @@ -40,6 +40,23 @@ public void When_ConstrainedDynamicSegment_Then_Removed() McpToolNameFlattener.Flatten("contact {id:guid} show", '_').Should().Be("contact_show"); } + [TestMethod] + [Description("Constrained dynamic segments with hyphenated constraint names are removed.")] + public void When_HyphenatedConstraintDynamicSegment_Then_Removed() + { + McpToolNameFlattener.Flatten("event {start:date-time} show", '_').Should().Be("event_show"); + } + + [TestMethod] + [Description("Resource URI generation strips constraints and includes contexts.")] + public void When_ResourceUriBuiltFromConstrainedRoute_Then_UsesVariableNamesOnly() + { + McpToolNameFlattener.BuildResourceUri("viewer session {id:int} attach", "ui") + .Should().Be("ui://viewer/session/{id}/attach"); + McpToolNameFlattener.BuildResourceUri("tenant {slug:tenant-slug} panel", "ui") + .Should().Be("ui://tenant/{slug}/panel"); + } + [TestMethod] [Description("Slash separator works.")] public void When_SlashSeparator_Then_UsesSlash()