From 1fd19365e34ed6826ebf97d74e09fd79ac2fc05a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Apr 2026 23:25:01 -0400 Subject: [PATCH 1/5] Add MCP Apps support --- README.md | 4 +- docs/mcp-server.md | 86 +++++- samples/08-mcp-server/Program.cs | 78 +++++- samples/08-mcp-server/README.md | 3 + samples/README.md | 3 + .../McpAppCommandBuilderExtensions.cs | 83 ++++++ src/Repl.Mcp/McpAppCommandResourceOptions.cs | 5 + src/Repl.Mcp/McpAppCsp.cs | 27 ++ src/Repl.Mcp/McpAppDisplayModes.cs | 16 ++ src/Repl.Mcp/McpAppMetadata.cs | 138 ++++++++++ src/Repl.Mcp/McpAppPermissions.cs | 19 ++ src/Repl.Mcp/McpAppResource.cs | 60 +++++ src/Repl.Mcp/McpAppResourceContext.cs | 7 + src/Repl.Mcp/McpAppResourceInvoker.cs | 109 ++++++++ src/Repl.Mcp/McpAppResourceOptions.cs | 50 ++++ src/Repl.Mcp/McpAppResourceRegistration.cs | 6 + src/Repl.Mcp/McpAppToolOptions.cs | 13 + src/Repl.Mcp/McpAppValidation.cs | 16 ++ src/Repl.Mcp/McpAppVisibility.cs | 17 ++ src/Repl.Mcp/McpJsonContext.cs | 1 + src/Repl.Mcp/McpServerHandler.cs | 76 +++++- src/Repl.Mcp/README.md | 23 +- src/Repl.Mcp/ReplMcpServerOptions.cs | 158 +++++++++++ src/Repl.Mcp/ReplMcpServerTool.cs | 17 ++ src/Repl.Mcp/ReplMcpServerUiResource.cs | 178 ++++++++++++ src/Repl.McpTests/Given_McpApps.cs | 253 ++++++++++++++++++ 26 files changed, 1428 insertions(+), 18 deletions(-) create mode 100644 src/Repl.Mcp/McpAppCommandBuilderExtensions.cs create mode 100644 src/Repl.Mcp/McpAppCommandResourceOptions.cs create mode 100644 src/Repl.Mcp/McpAppCsp.cs create mode 100644 src/Repl.Mcp/McpAppDisplayModes.cs create mode 100644 src/Repl.Mcp/McpAppMetadata.cs create mode 100644 src/Repl.Mcp/McpAppPermissions.cs create mode 100644 src/Repl.Mcp/McpAppResource.cs create mode 100644 src/Repl.Mcp/McpAppResourceContext.cs create mode 100644 src/Repl.Mcp/McpAppResourceInvoker.cs create mode 100644 src/Repl.Mcp/McpAppResourceOptions.cs create mode 100644 src/Repl.Mcp/McpAppResourceRegistration.cs create mode 100644 src/Repl.Mcp/McpAppToolOptions.cs create mode 100644 src/Repl.Mcp/McpAppValidation.cs create mode 100644 src/Repl.Mcp/McpAppVisibility.cs create mode 100644 src/Repl.Mcp/ReplMcpServerUiResource.cs create mode 100644 src/Repl.McpTests/Given_McpApps.cs diff --git a/README.md b/README.md index 923cfc4..1015634 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,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 — expose commands as AI agent tools and inline MCP Apps | [![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 +115,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 for AI agents, including a minimal MCP Apps UI ## More documentation diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 8dc8091..f00f035 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -2,7 +2,7 @@ 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: @@ -170,6 +170,90 @@ app.Map("deploy {env}", handler) > }); > ``` +## MCP Apps + +MCP Apps let a tool render an interactive HTML UI inline in clients that support the `io.modelcontextprotocol/ui` extension. Repl.Mcp exposes this through `ui://` resources and tool metadata. + +```csharp +app.Map("contacts dashboard", (IContactDb contacts) => + $""" + + + +

Contacts

+

{contacts.GetAll().Count} contacts loaded from Repl.

+ + + """) + .WithDescription("Open the contacts dashboard") + .AsMcpAppResource(resource => + { + resource.Name = "Contacts Dashboard"; + resource.Description = "Minimal contacts dashboard."; + resource.PrefersBorder = true; + }); +``` + +What happens: + +- `AsMcpAppResource(...)` marks the mapped command as an MCP App resource and links the tool declaration to it with `_meta.ui.resourceUri`. +- The `ui://` resource URI is generated from the route path, the same way `AsResource()` generates `repl://` URIs. +- The command handler runs through the normal Repl pipeline, so services can be injected just like other mapped commands. +- `resources/read` returns `text/html;profile=mcp-app`. +- Clients that support MCP Apps render the HTML inline. +- Clients that do not support MCP Apps ignore the UI metadata and still receive the tool's normal text result. + +If the mapped command only exists to render app HTML, keep a small model-visible launcher tool and mark the HTML-producing resource command as app-only: + +```csharp +app.Map("contacts dashboard", () => "Opening the contacts dashboard.") + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard"); + +app.Map("contacts dashboard app", (IContactDb contacts) => BuildHtml(contacts)) + .AsMcpAppResource( + "ui://contacts/dashboard", + visibility: McpAppVisibility.App, + preferredDisplayMode: McpAppDisplayModes.Fullscreen); +``` + +Use the default `ModelAndApp` visibility only when the same command returns a useful plain-text fallback for the model. +Hosts decide which display modes they support; standard MCP Apps display mode values are `inline`, `fullscreen`, and `pip`. +For experimental or host-specific presentation hints, add custom UI metadata: + +```csharp +app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts)) + .AsMcpAppResource(resource => + { + resource.UiMetadata["presentation"] = "flyout"; + }); +``` + +For UI that loads external assets, declare the domains with CSP metadata: + +```csharp +app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts)) + .AsMcpAppResource(resource => + { + resource.Csp = 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"); +``` + +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: diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs index b29127b..f3de275 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,68 @@ // 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", () => "Opening the contacts dashboard.") + .WithDescription("Open the contacts dashboard") + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard"); + +app.Map("contacts dashboard app", + (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.

    + + + + """; + }) + .WithDescription("Render the contacts dashboard app") + .AsMcpAppResource("ui://contacts/dashboard", resource => + { + resource.Name = "Contacts Dashboard"; + resource.Description = "Minimal contacts dashboard."; + resource.PrefersBorder = true; + }, visibility: McpAppVisibility.App, preferredDisplayMode: 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 +124,20 @@ 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 ? _contacts[0] : _contacts[1]; +} diff --git a/samples/08-mcp-server/README.md b/samples/08-mcp-server/README.md index f28f2a7..1000b3c 100644 --- a/samples/08-mcp-server/README.md +++ b/samples/08-mcp-server/README.md @@ -8,6 +8,7 @@ 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(..., visibility: McpAppVisibility.App, preferredDisplayMode: ...)` — expose generated HTML as an app-only MCP App with a display preference - `.AutomationHidden()` — hide interactive-only commands from agents - `.WithDetails()` — rich descriptions that serve both `--help` and agents @@ -31,6 +32,8 @@ dotnet run -- mcp serve npx @modelcontextprotocol/inspector dotnet run --project . -- mcp serve ``` +Clients with MCP Apps support render the `contacts dashboard` tool's `ui://contacts/dashboard` resource inline. Other clients still receive the normal text fallback from the tool result. + ## 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..462e2b1 --- /dev/null +++ b/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs @@ -0,0 +1,83 @@ +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. + /// Optional resource metadata configuration. + /// 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, + Action? configure = null, + McpAppVisibility visibility = McpAppVisibility.ModelAndApp, + string? preferredDisplayMode = null) + { + ArgumentNullException.ThrowIfNull(builder); + var resourceUri = McpToolNameFlattener.BuildResourceUri(builder.Route, "ui"); + return builder.AsMcpAppResource(resourceUri, configure, 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. + /// Optional resource metadata configuration. + /// 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, + Action? configure = null, + McpAppVisibility visibility = McpAppVisibility.ModelAndApp, + string? preferredDisplayMode = null) + { + ArgumentNullException.ThrowIfNull(builder); + McpAppValidation.ThrowIfInvalidUiUri(resourceUri); + + var options = new McpAppResourceOptions(); + configure?.Invoke(options); + options.Name ??= resourceUri; + options.PreferredDisplayMode ??= preferredDisplayMode; + + builder + .ReadOnly() + .AsResource() + .WithMcpApp(resourceUri, visibility) + .WithMetadata( + McpAppMetadata.ResourceMetadataKey, + new McpAppCommandResourceOptions(resourceUri, options)); + + return builder; + } +} diff --git a/src/Repl.Mcp/McpAppCommandResourceOptions.cs b/src/Repl.Mcp/McpAppCommandResourceOptions.cs new file mode 100644 index 0000000..510fc19 --- /dev/null +++ b/src/Repl.Mcp/McpAppCommandResourceOptions.cs @@ -0,0 +1,5 @@ +namespace Repl.Mcp; + +internal sealed record McpAppCommandResourceOptions( + string ResourceUri, + McpAppResourceOptions ResourceOptions); 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..13edd4e --- /dev/null +++ b/src/Repl.Mcp/McpAppResourceInvoker.cs @@ -0,0 +1,109 @@ +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) + { + throw ex.InnerException; + } + + 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..013858a --- /dev/null +++ b/src/Repl.Mcp/McpAppResourceOptions.cs @@ -0,0 +1,50 @@ +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; } + + /// + /// 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..829fdc5 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() { @@ -755,6 +788,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 +811,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/README.md b/src/Repl.Mcp/README.md index 901411b..de237e5 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -17,6 +17,23 @@ myapp mcp serve # AI agents connect here myapp # still a CLI / interactive REPL ``` +## MCP Apps + +Repl.Mcp can also expose inline MCP Apps UI resources: + +```csharp +app.Map("contacts dashboard", (ContactStore contacts) => + $"{contacts.All.Count} contacts") + .WithDescription("Open the contacts dashboard") + .AsMcpAppResource("ui://contacts/dashboard", resource => + { + resource.Name = "Contacts Dashboard"; + resource.PrefersBorder = true; + }, visibility: McpAppVisibility.App, preferredDisplayMode: McpAppDisplayModes.Fullscreen); +``` + +Clients with MCP Apps support render the `ui://` resource inline. Other MCP clients still receive the tool's normal text result. + ## What agents see | You write | Agents get | @@ -24,7 +41,11 @@ 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 | +| `.AsMcpAppResource(visibility: McpAppVisibility.App)` | MCP Apps tool hidden from the model | +| `.AsMcpAppResource(preferredDisplayMode: McpAppDisplayModes.Fullscreen)` | MCP Apps display preference | | `.AsPrompt()` | MCP prompt template | +| `.WithMcpApp("ui://...")` | MCP Apps UI resource link | | `.AutomationHidden()` | Not visible to agents | | `{id:guid}` | `{ "type": "string", "format": "uuid" }` | | `[Description("...")]` | Schema `description` field | @@ -36,4 +57,4 @@ Claude Desktop, Claude Code, VS Code Copilot, Cursor, and any MCP-compatible age ## 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/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/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..dac5543 --- /dev/null +++ b/src/Repl.Mcp/ReplMcpServerUiResource.cs @@ -0,0 +1,178 @@ +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 ?? resourceName, + 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) + .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 name in _variableNames) + { + if (match.Groups[name] is { Success: true } group) + { + var value = Uri.UnescapeDataString(group.Value); + arguments[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; + } + } +} diff --git a/src/Repl.McpTests/Given_McpApps.cs b/src/Repl.McpTests/Given_McpApps.cs new file mode 100644 index 0000000..a255ca8 --- /dev/null +++ b/src/Repl.McpTests/Given_McpApps.cs @@ -0,0 +1,253 @@ +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(resource => + { + resource.Name = "Contacts Dashboard"; + resource.PrefersBorder = true; + }); + }, + 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("AsMcpAppResource 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, + preferredDisplayMode: 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("MCP App resource options 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(resource => + { + resource.UiMetadata["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("A model-visible launcher tool can point at an app-only HTML resource command.")] + public async Task When_ModelLauncherUsesAppOnlyResource_Then_ModelToolDoesNotReturnHtml() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("contacts dashboard", () => "Opening the contacts dashboard.") + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard"); + + app.Map("contacts dashboard app", () => "Contacts") + .AsMcpAppResource("ui://contacts/dashboard", visibility: McpAppVisibility.App); + }).ConfigureAwait(false); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + var launcher = tools.Single(tool => + string.Equals(tool.Name, "contacts_dashboard", StringComparison.Ordinal)); + var appOnly = tools.Single(tool => + string.Equals(tool.Name, "contacts_dashboard_app", StringComparison.Ordinal)); + var launcherUi = launcher.ProtocolTool.Meta!["ui"]!.AsObject(); + var appOnlyUi = appOnly.ProtocolTool.Meta!["ui"]!.AsObject(); + + launcherUi["visibility"]!.AsArray().Select(static node => node!.GetValue()) + .Should().BeEquivalentTo(["model", "app"]); + appOnlyUi["visibility"]!.AsArray().Select(static node => node!.GetValue()) + .Should().ContainSingle("app"); + + var toolResult = await fixture.Client.CallToolAsync("contacts_dashboard").ConfigureAwait(false); + toolResult.Content.OfType().Single().Text + .Should().Contain("Opening the contacts dashboard."); + + var resourceResult = await fixture.Client.ReadResourceAsync("ui://contacts/dashboard").ConfigureAwait(false); + resourceResult.Contents.OfType().Single().Text + .Should().Contain("Contacts"); + } + + [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"); + } + + private sealed record DashboardService(string Title); +} From 490253a26fa0df1c2748efc134daa0e63e477ebb Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Apr 2026 23:28:26 -0400 Subject: [PATCH 2/5] Document MCP Apps patterns --- docs/mcp-advanced.md | 130 ++++++++++++++++++++++++++++++++++++++++++- docs/mcp-server.md | 66 +++++++++++----------- 2 files changed, 160 insertions(+), 36 deletions(-) diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md index 4ca92cf..a1716cb 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,134 @@ 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. + +### Launcher tool plus app-only resource + +If a mapped command returns generated HTML, do not always expose that command directly to the model. Some hosts can show the tool result text in the chat transcript, which means the model may treat the HTML as normal content. + +Prefer a small model-visible launcher tool plus a separate app-only HTML resource command: + +```csharp +app.Map("contacts dashboard", () => "Opening the contacts dashboard.") + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard"); + +app.Map("contacts dashboard app", (IContactDb contacts) => BuildHtml(contacts)) + .AsMcpAppResource( + "ui://contacts/dashboard", + resource => + { + resource.Name = "Contacts Dashboard"; + resource.PrefersBorder = true; + }, + visibility: McpAppVisibility.App); +``` + +The first command gives the model something useful and short to call. The second command is still a normal Repl mapping, so it can use dependency injection, cancellation tokens, and the usual command pipeline, but its tool metadata is `visibility: ["app"]`. + +Use the single-command pattern only when the command returns both a good text fallback and useful UI metadata: + +```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`. + +Pass an explicit URI when a launcher tool and app-only resource command need to share the same app resource: + +```csharp +app.Map("contacts dashboard app", (IContactDb contacts) => BuildHtml(contacts)) + .AsMcpAppResource("ui://contacts/dashboard", visibility: McpAppVisibility.App); +``` + +### 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 app", (IContactDb contacts) => BuildHtml(contacts)) + .AsMcpAppResource( + "ui://contacts/dashboard", + visibility: McpAppVisibility.App, + preferredDisplayMode: 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 app", (IContactDb contacts) => BuildHtml(contacts)) + .AsMcpAppResource(resource => + { + resource.UiMetadata["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 app", () => BuildUnoShellHtml(assetBaseUri)) + .AsMcpAppResource("ui://contacts/dashboard", resource => + { + resource.Csp = new McpAppCsp + { + ResourceDomains = [assetBaseUri.ToString()], + ConnectDomains = [assetBaseUri.ToString()], + }; + }, visibility: McpAppVisibility.App); +``` + +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 the launcher plus app-only resource pattern. The model-visible launcher should return a short text result and point at the `ui://` resource with `.WithMcpApp(...)`; the HTML-producing command should use `.AsMcpAppResource(..., visibility: McpAppVisibility.App)`. + +Also restart or reload the MCP server in the client. Some hosts cache tool lists and will not pick up `_meta.ui.visibility` 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 f00f035..cdc69e6 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -6,7 +6,7 @@ See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working MCP se 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 @@ -172,62 +172,44 @@ app.Map("deploy {env}", handler) ## MCP Apps -MCP Apps let a tool render an interactive HTML UI inline in clients that support the `io.modelcontextprotocol/ui` extension. Repl.Mcp exposes this through `ui://` resources and tool metadata. +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. ```csharp -app.Map("contacts dashboard", (IContactDb contacts) => - $""" - - - -

    Contacts

    -

    {contacts.GetAll().Count} contacts loaded from Repl.

    - - - """) +app.Map("contacts dashboard", () => "Opening the contacts dashboard.") .WithDescription("Open the contacts dashboard") - .AsMcpAppResource(resource => + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard"); + +app.Map("contacts dashboard app", (IContactDb contacts) => BuildHtml(contacts)) + .WithDescription("Render the contacts dashboard app") + .AsMcpAppResource("ui://contacts/dashboard", resource => { resource.Name = "Contacts Dashboard"; resource.Description = "Minimal contacts dashboard."; resource.PrefersBorder = true; - }); + }, visibility: McpAppVisibility.App); ``` What happens: -- `AsMcpAppResource(...)` marks the mapped command as an MCP App resource and links the tool declaration to it with `_meta.ui.resourceUri`. -- The `ui://` resource URI is generated from the route path, the same way `AsResource()` generates `repl://` URIs. -- The command handler runs through the normal Repl pipeline, so services can be injected just like other mapped commands. +- `WithMcpApp(...)` links the model-visible launcher tool to the UI resource with `_meta.ui.resourceUri`. +- `AsMcpAppResource(...)` maps the HTML-producing command as the `ui://` resource and hides that command from the model with `visibility: ["app"]`. +- The HTML command handler runs through the normal Repl pipeline, so services can be injected just like other mapped commands. - `resources/read` returns `text/html;profile=mcp-app`. -- Clients that support MCP Apps render the HTML inline. +- 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. -If the mapped command only exists to render app HTML, keep a small model-visible launcher tool and mark the HTML-producing resource command as app-only: +For simpler cases where one command returns both a useful text fallback and useful UI metadata, use a single command: ```csharp -app.Map("contacts dashboard", () => "Opening the contacts dashboard.") +app.Map("contacts dashboard", (IContactDb contacts) => contacts.GetSummary()) .ReadOnly() .WithMcpApp("ui://contacts/dashboard"); - -app.Map("contacts dashboard app", (IContactDb contacts) => BuildHtml(contacts)) - .AsMcpAppResource( - "ui://contacts/dashboard", - visibility: McpAppVisibility.App, - preferredDisplayMode: McpAppDisplayModes.Fullscreen); ``` Use the default `ModelAndApp` visibility only when the same command returns a useful plain-text fallback for the model. Hosts decide which display modes they support; standard MCP Apps display mode values are `inline`, `fullscreen`, and `pip`. -For experimental or host-specific presentation hints, add custom UI metadata: - -```csharp -app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts)) - .AsMcpAppResource(resource => - { - resource.UiMetadata["presentation"] = "flyout"; - }); -``` +See [mcp-advanced.md](mcp-advanced.md#mcp-apps-advanced-patterns) for launcher/resource patterns, app-only tools, display modes, and WebAssembly assets. For UI that loads external assets, declare the domains with CSP metadata: @@ -461,8 +443,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 }); ``` @@ -485,6 +469,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 From a282a367d499710384ddd8e4add03ff218970c69 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 6 Apr 2026 23:41:45 -0400 Subject: [PATCH 3/5] Feature MCP Apps in documentation --- README.md | 17 ++++-- docs/architecture.md | 4 +- docs/best-practices.md | 15 ++++++ docs/comparison.md | 4 +- docs/glossary.md | 4 ++ docs/help-system.md | 2 +- docs/mcp-advanced.md | 15 ++++++ docs/mcp-server.md | 5 ++ samples/08-mcp-server/README.md | 10 +++- samples/README.md | 2 +- src/Repl.Mcp/McpToolAdapter.cs | 2 +- src/Repl.Mcp/McpToolNameFlattener.cs | 6 +-- src/Repl.Mcp/README.md | 17 ++++-- src/Repl.McpTests/Given_McpApps.cs | 54 +++++++++++++++++++ .../Given_McpToolNameFlattener.cs | 17 ++++++ 15 files changed, 155 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1015634..fb23c6f 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,17 @@ 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", () => "Opening the contacts dashboard.") + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard"); + +app.Map("contacts dashboard app", (IContactStore contacts) => BuildHtml(contacts)) + .AsMcpAppResource("ui://contacts/dashboard", visibility: McpAppVisibility.App); +``` + One command graph. CLI, REPL, remote sessions, and AI agents — all from the same code. ## What's included @@ -93,7 +104,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) |
    • [Interactive loop](docs/interactive-loop.md)
    • [Configuration](docs/configuration-reference.md)
    | | 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) |
    • [Parameter system](docs/parameter-system.md)
    • [Route system](docs/route-system.md)
    | | 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) |
    • [Output system](docs/output-system.md)
    | -| MCP server — expose commands as AI agent tools and inline MCP Apps | [![Repl.Mcp](https://img.shields.io/nuget/vpre/Repl.Mcp?logo=nuget&label=Repl.Mcp)](https://www.nuget.org/packages/Repl.Mcp) |
    • [MCP server](docs/mcp-server.md)
    • [MCP advanced](docs/mcp-advanced.md)
    • [MCP sample](samples/08-mcp-server/)
    | +| 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) |
    • [MCP server](docs/mcp-server.md)
    • [MCP advanced](docs/mcp-advanced.md)
    • [MCP sample](samples/08-mcp-server/)
    | | 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) |
    • [Interaction channel](docs/interaction.md)
    | | 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) |
    • [Runtime channels](docs/runtime-channels.md)
    • [Terminal metadata](docs/terminal-metadata.md)
    | | 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) |
    • [Shell completion](docs/shell-completion.md)
    | @@ -115,7 +126,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, including a minimal MCP Apps UI +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..ed22bda 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -229,6 +229,21 @@ app.Map("clear", static async (IReplInteractionChannel ch, CancellationToken ct) .AutomationHidden(); // not exposed to agents ``` +For MCP Apps, keep the model-facing command short and use an app-only resource for generated HTML: + +```csharp +app.Map("contacts dashboard", static () => "Opening the contacts dashboard.") + .WithDescription("Open the contacts dashboard") + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard"); + +app.Map("contacts dashboard app", static (IContactStore contacts) => BuildHtml(contacts)) + .WithDescription("Render the contacts dashboard app") + .AsMcpAppResource("ui://contacts/dashboard", visibility: McpAppVisibility.App); +``` + +This lets capable hosts render the UI while keeping raw HTML out of the model-facing transcript. The HTML 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..c44259c 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 | ❌ | ❌ | ✅ `WithMcpApp()` + `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..68f475a 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 tool point to a `ui://` HTML resource. Repl maps this with `.WithMcpApp(...)` on the launcher command and `.AsMcpAppResource(...)` on the HTML-producing resource command. + ### 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 a1716cb..f516bb2 100644 --- a/docs/mcp-advanced.md +++ b/docs/mcp-advanced.md @@ -297,6 +297,21 @@ app.Map("contact {id:int} panel", (int id, IContactDb contacts) => BuildHtml(con 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 when a launcher tool and app-only resource command need to share the same app resource: ```csharp diff --git a/docs/mcp-server.md b/docs/mcp-server.md index cdc69e6..58d4cfd 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -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, unless app-only | 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. @@ -196,6 +198,7 @@ What happens: - `AsMcpAppResource(...)` maps the HTML-producing command as the `ui://` resource and hides that command from the model with `visibility: ["app"]`. - The HTML command handler runs through the normal Repl pipeline, so services can be injected just like other mapped commands. - `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. @@ -232,6 +235,8 @@ 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. diff --git a/samples/08-mcp-server/README.md b/samples/08-mcp-server/README.md index 1000b3c..ed20dad 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,7 @@ 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 +- `.WithMcpApp("ui://...")` — attach a model-visible launcher tool to an MCP App - `.AsMcpAppResource(..., visibility: McpAppVisibility.App, preferredDisplayMode: ...)` — expose generated HTML as an app-only MCP App with a display preference - `.AutomationHidden()` — hide interactive-only commands from agents - `.WithDetails()` — rich descriptions that serve both `--help` and agents @@ -32,7 +33,12 @@ dotnet run -- mcp serve npx @modelcontextprotocol/inspector dotnet run --project . -- mcp serve ``` -Clients with MCP Apps support render the `contacts dashboard` tool's `ui://contacts/dashboard` resource inline. Other clients still receive the normal text fallback from the tool result. +Clients with MCP Apps support render the `contacts dashboard` tool's `ui://contacts/dashboard` resource. Other clients still receive the normal text fallback from the launcher tool result. + +The sample intentionally uses two commands for the dashboard: + +- `contacts dashboard` is visible to the model and returns a short text fallback. +- `contacts dashboard app` generates the HTML and is marked `visibility: McpAppVisibility.App`, so capable hosts can call it without exposing raw HTML as model-facing text. ## Agent configuration diff --git a/samples/README.md b/samples/README.md index 02bf051..cd9f24e 100644 --- a/samples/README.md +++ b/samples/README.md @@ -21,7 +21,7 @@ If you’re new, start with **01**, then follow the sequence. 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. + MCP server mode: tools, resources, prompts, behavioral annotations, automation visibility, and a launcher-backed minimal MCP Apps UI. ## Run diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 6589cf7..a3bfa4d 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -203,6 +203,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 de237e5..0bc4fa6 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 @@ -19,12 +19,17 @@ myapp # still a CLI / interactive REPL ## MCP Apps -Repl.Mcp can also expose inline MCP Apps UI resources: +Repl.Mcp can also expose MCP Apps UI resources: ```csharp -app.Map("contacts dashboard", (ContactStore contacts) => - $"{contacts.All.Count} contacts") +app.Map("contacts dashboard", () => "Opening the contacts dashboard.") .WithDescription("Open the contacts dashboard") + .ReadOnly() + .WithMcpApp("ui://contacts/dashboard"); + +app.Map("contacts dashboard app", (ContactStore contacts) => + $"{contacts.All.Count} contacts") + .WithDescription("Render the contacts dashboard app") .AsMcpAppResource("ui://contacts/dashboard", resource => { resource.Name = "Contacts Dashboard"; @@ -32,7 +37,7 @@ app.Map("contacts dashboard", (ContactStore contacts) => }, visibility: McpAppVisibility.App, preferredDisplayMode: McpAppDisplayModes.Fullscreen); ``` -Clients with MCP Apps support render the `ui://` resource inline. Other MCP clients still receive the tool's normal text result. +Clients with MCP Apps support render the `ui://` resource. Other MCP clients still receive the launcher tool's normal text result. ## What agents see @@ -54,6 +59,8 @@ Clients with MCP Apps support render the `ui://` resource inline. Other MCP clie 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 diff --git a/src/Repl.McpTests/Given_McpApps.cs b/src/Repl.McpTests/Given_McpApps.cs index a255ca8..feb6755 100644 --- a/src/Repl.McpTests/Given_McpApps.cs +++ b/src/Repl.McpTests/Given_McpApps.cs @@ -249,5 +249,59 @@ public async Task When_CommandIsParameterizedMcpAppResource_Then_UiUriTemplateBi 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() From 88e5e21418b71b8825ba8a4a3c65e206bfbbcd03 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 7 Apr 2026 00:09:43 -0400 Subject: [PATCH 4/5] Simplify MCP Apps resource mapping --- README.md | 9 +- docs/best-practices.md | 13 +-- docs/comparison.md | 2 +- docs/glossary.md | 2 +- docs/mcp-advanced.md | 75 ++++++------ docs/mcp-server.md | 44 +++---- samples/08-mcp-server/Program.cs | 19 +--- samples/08-mcp-server/README.md | 11 +- samples/README.md | 2 +- .../McpAppCommandBuilderExtensions.cs | 107 ++++++++++++++++-- src/Repl.Mcp/McpAppCommandResourceOptions.cs | 3 +- src/Repl.Mcp/McpAppResourceOptions.cs | 6 + src/Repl.Mcp/McpServerHandler.cs | 34 ++++++ src/Repl.Mcp/McpToolAdapter.cs | 32 +++++- src/Repl.Mcp/README.md | 26 ++--- src/Repl.Mcp/ReplMcpAppLauncherTool.cs | 63 +++++++++++ src/Repl.Mcp/ReplMcpServerResource.cs | 7 +- src/Repl.Mcp/ReplMcpServerUiResource.cs | 14 ++- src/Repl.McpTests/Given_McpApps.cs | 60 +++++----- 19 files changed, 359 insertions(+), 170 deletions(-) create mode 100644 src/Repl.Mcp/ReplMcpAppLauncherTool.cs diff --git a/README.md b/README.md index fb23c6f..96c8367 100644 --- a/README.md +++ b/README.md @@ -86,12 +86,9 @@ app.UseMcpServer(); // add one line **MCP Apps** (same server, host-rendered UI for capable clients): ```csharp -app.Map("contacts dashboard", () => "Opening the contacts dashboard.") - .ReadOnly() - .WithMcpApp("ui://contacts/dashboard"); - -app.Map("contacts dashboard app", (IContactStore contacts) => BuildHtml(contacts)) - .AsMcpAppResource("ui://contacts/dashboard", visibility: McpAppVisibility.App); +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. diff --git a/docs/best-practices.md b/docs/best-practices.md index ed22bda..b17609e 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -229,20 +229,15 @@ app.Map("clear", static async (IReplInteractionChannel ch, CancellationToken ct) .AutomationHidden(); // not exposed to agents ``` -For MCP Apps, keep the model-facing command short and use an app-only resource for generated HTML: +For MCP Apps, mark the HTML-producing command as an app resource: ```csharp -app.Map("contacts dashboard", static () => "Opening the contacts dashboard.") +app.Map("contacts dashboard", static (IContactStore contacts) => BuildHtml(contacts)) .WithDescription("Open the contacts dashboard") - .ReadOnly() - .WithMcpApp("ui://contacts/dashboard"); - -app.Map("contacts dashboard app", static (IContactStore contacts) => BuildHtml(contacts)) - .WithDescription("Render the contacts dashboard app") - .AsMcpAppResource("ui://contacts/dashboard", visibility: McpAppVisibility.App); + .AsMcpAppResource(); ``` -This lets capable hosts render the UI while keeping raw HTML out of the model-facing transcript. The HTML handler is still a normal Repl mapping, so it can use DI, cancellation tokens, and the usual command pipeline. +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: diff --git a/docs/comparison.md b/docs/comparison.md index c44259c..832e883 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -89,7 +89,7 @@ Repl Toolkit is a command-surface framework — not just a CLI parser. It builds | Documentation export | ❌ | ❌ | ✅ `doc export` command | | Protocol passthrough (MCP, LSP...) | ❌ | ❌ | ✅ `AsProtocolPassthrough()` | | MCP server tools/resources/prompts | ❌ | ❌ | ✅ `Repl.Mcp` | -| MCP Apps UI resources | ❌ | ❌ | ✅ `WithMcpApp()` + `AsMcpAppResource()` | +| MCP Apps UI resources | ❌ | ❌ | ✅ `AsMcpAppResource()` | | Shell completion | ⚠️ Tab completion API | ❌ | ✅ Bash, PS, Zsh, Fish, Nu | ## When to Use What diff --git a/docs/glossary.md b/docs/glossary.md index 68f475a..4c862cd 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -68,7 +68,7 @@ Model Context Protocol. Allows AI agents to discover and invoke commands. ### MCP App -MCP UI extension that lets a tool point to a `ui://` HTML resource. Repl maps this with `.WithMcpApp(...)` on the launcher command and `.AsMcpAppResource(...)` on the HTML-producing resource command. +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 diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md index f516bb2..6115a26 100644 --- a/docs/mcp-advanced.md +++ b/docs/mcp-advanced.md @@ -254,31 +254,30 @@ Avoid it when: 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. -### Launcher tool plus app-only resource +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. -If a mapped command returns generated HTML, do not always expose that command directly to the model. Some hosts can show the tool result text in the chat transcript, which means the model may treat the HTML as normal content. +### One mapping, two MCP surfaces -Prefer a small model-visible launcher tool plus a separate app-only HTML resource command: +`AsMcpAppResource()` keeps the Repl authoring model simple: one mapping produces both the launcher tool metadata and the UI resource. ```csharp -app.Map("contacts dashboard", () => "Opening the contacts dashboard.") - .ReadOnly() - .WithMcpApp("ui://contacts/dashboard"); - -app.Map("contacts dashboard app", (IContactDb contacts) => BuildHtml(contacts)) - .AsMcpAppResource( - "ui://contacts/dashboard", - resource => - { - resource.Name = "Contacts Dashboard"; - resource.PrefersBorder = true; - }, - visibility: McpAppVisibility.App); +app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts)) + .WithDescription("Open the contacts dashboard") + .AsMcpAppResource(); ``` -The first command gives the model something useful and short to call. The second command is still a normal Repl mapping, so it can use dependency injection, cancellation tokens, and the usual command pipeline, but its tool metadata is `visibility: ["app"]`. +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 the single-command pattern only when the command returns both a good text fallback and useful UI metadata: +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()) @@ -312,11 +311,11 @@ app.Context("viewer", viewer => 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 when a launcher tool and app-only resource command need to share the same app resource: +Pass an explicit URI only when you need a stable public URI that is decoupled from the route path: ```csharp -app.Map("contacts dashboard app", (IContactDb contacts) => BuildHtml(contacts)) - .AsMcpAppResource("ui://contacts/dashboard", visibility: McpAppVisibility.App); +app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts)) + .AsMcpAppResource("ui://contacts/summary"); ``` ### Display preferences @@ -324,11 +323,9 @@ app.Map("contacts dashboard app", (IContactDb contacts) => BuildHtml(contacts)) 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 app", (IContactDb contacts) => BuildHtml(contacts)) - .AsMcpAppResource( - "ui://contacts/dashboard", - visibility: McpAppVisibility.App, - preferredDisplayMode: McpAppDisplayModes.Fullscreen); +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. @@ -345,11 +342,9 @@ if (modes.includes("fullscreen")) { For host-specific hints that are not yet modeled by Repl, use simple string metadata: ```csharp -app.Map("contacts dashboard app", (IContactDb contacts) => BuildHtml(contacts)) - .AsMcpAppResource(resource => - { - resource.UiMetadata["presentation"] = "flyout"; - }); +app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts)) + .AsMcpAppResource() + .WithMcpAppUiMetadata("presentation", "flyout"); ``` ### HTML now, assets later @@ -366,15 +361,13 @@ For WebAssembly UIs such as Uno-Wasm, the likely shape is: ```csharp var assetBaseUri = new Uri("http://127.0.0.1:5123/"); -app.Map("contacts dashboard app", () => BuildUnoShellHtml(assetBaseUri)) - .AsMcpAppResource("ui://contacts/dashboard", resource => +app.Map("contacts dashboard", () => BuildUnoShellHtml(assetBaseUri)) + .AsMcpAppResource() + .WithMcpAppCsp(new McpAppCsp { - resource.Csp = new McpAppCsp - { - ResourceDomains = [assetBaseUri.ToString()], - ConnectDomains = [assetBaseUri.ToString()], - }; - }, visibility: McpAppVisibility.App); + 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. @@ -383,9 +376,9 @@ Keep the shell and asset server host-aware: clients may preload or cache UI reso ### My MCP App shows HTML text in the chat -Use the launcher plus app-only resource pattern. The model-visible launcher should return a short text result and point at the `ui://` resource with `.WithMcpApp(...)`; the HTML-producing command should use `.AsMcpAppResource(..., visibility: McpAppVisibility.App)`. +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 `_meta.ui.visibility` changes until the server is refreshed. +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 diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 58d4cfd..3417d0b 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -158,7 +158,7 @@ app.Map("deploy {env}", handler) | `.ReadOnly().AsResource()` | Yes | Yes | No | | `.AsPrompt()` | No | No | Yes | | `.AsPrompt()` + `PromptFallbackToTools = true` | Yes | No | Yes | -| `.AsMcpAppResource()` | Yes, unless app-only | Yes (`ui://` HTML resource) | No | +| `.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. @@ -176,55 +176,37 @@ app.Map("deploy {env}", handler) 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", () => "Opening the contacts dashboard.") +app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts)) .WithDescription("Open the contacts dashboard") - .ReadOnly() - .WithMcpApp("ui://contacts/dashboard"); - -app.Map("contacts dashboard app", (IContactDb contacts) => BuildHtml(contacts)) - .WithDescription("Render the contacts dashboard app") - .AsMcpAppResource("ui://contacts/dashboard", resource => - { - resource.Name = "Contacts Dashboard"; - resource.Description = "Minimal contacts dashboard."; - resource.PrefersBorder = true; - }, visibility: McpAppVisibility.App); + .AsMcpAppResource() + .WithMcpAppBorder(); ``` What happens: -- `WithMcpApp(...)` links the model-visible launcher tool to the UI resource with `_meta.ui.resourceUri`. -- `AsMcpAppResource(...)` maps the HTML-producing command as the `ui://` resource and hides that command from the model with `visibility: ["app"]`. +- `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. -For simpler cases where one command returns both a useful text fallback and useful UI metadata, use a single command: - -```csharp -app.Map("contacts dashboard", (IContactDb contacts) => contacts.GetSummary()) - .ReadOnly() - .WithMcpApp("ui://contacts/dashboard"); -``` - -Use the default `ModelAndApp` visibility only when the same command returns a useful plain-text fallback for the model. 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 launcher/resource patterns, app-only tools, display modes, and WebAssembly assets. +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(resource => + .AsMcpAppResource() + .WithMcpAppCsp(new McpAppCsp { - resource.Csp = new McpAppCsp - { - ResourceDomains = ["https://cdn.example.com"], - ConnectDomains = ["https://api.example.com"], - }; + ResourceDomains = ["https://cdn.example.com"], + ConnectDomains = ["https://api.example.com"], }); ``` diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs index f3de275..8aa4bbc 100644 --- a/samples/08-mcp-server/Program.cs +++ b/samples/08-mcp-server/Program.cs @@ -29,13 +29,7 @@ .ReadOnly() .AsResource(); -app.Map("contacts dashboard", () => "Opening the contacts dashboard.") - .WithDescription("Open the contacts dashboard") - .ReadOnly() - .WithMcpApp("ui://contacts/dashboard"); - -app.Map("contacts dashboard app", - (ContactStore contacts) => +app.Map("contacts dashboard", (ContactStore contacts) => { var items = string.Join( "", @@ -60,13 +54,10 @@ """; }) - .WithDescription("Render the contacts dashboard app") - .AsMcpAppResource("ui://contacts/dashboard", resource => - { - resource.Name = "Contacts Dashboard"; - resource.Description = "Minimal contacts dashboard."; - resource.PrefersBorder = true; - }, visibility: McpAppVisibility.App, preferredDisplayMode: McpAppDisplayModes.Fullscreen); + .WithDescription("Open the contacts dashboard") + .AsMcpAppResource() + .WithMcpAppBorder() + .WithMcpAppDisplayMode(McpAppDisplayModes.Fullscreen); // ── Contact operations (grouped context) ─────────────────────────── diff --git a/samples/08-mcp-server/README.md b/samples/08-mcp-server/README.md index ed20dad..e88089a 100644 --- a/samples/08-mcp-server/README.md +++ b/samples/08-mcp-server/README.md @@ -8,8 +8,8 @@ Expose a Repl command graph as an MCP server for AI agents, including a minimal - `.ReadOnly()` / `.Destructive()` / `.OpenWorld()` — behavioral annotations - `.AsResource()` — mark data-to-consult commands as MCP resources - `.AsPrompt()` — mark commands as MCP prompt sources -- `.WithMcpApp("ui://...")` — attach a model-visible launcher tool to an MCP App -- `.AsMcpAppResource(..., visibility: McpAppVisibility.App, preferredDisplayMode: ...)` — expose generated HTML as an app-only MCP App with a display preference +- `.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 @@ -33,12 +33,9 @@ dotnet run -- mcp serve npx @modelcontextprotocol/inspector dotnet run --project . -- mcp serve ``` -Clients with MCP Apps support render the `contacts dashboard` tool's `ui://contacts/dashboard` resource. Other clients still receive the normal text fallback from the launcher tool result. +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. -The sample intentionally uses two commands for the dashboard: - -- `contacts dashboard` is visible to the model and returns a short text fallback. -- `contacts dashboard app` generates the HTML and is marked `visibility: McpAppVisibility.App`, so capable hosts can call it without exposing raw HTML as model-facing text. +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 diff --git a/samples/README.md b/samples/README.md index cd9f24e..02bf051 100644 --- a/samples/README.md +++ b/samples/README.md @@ -21,7 +21,7 @@ If you’re new, start with **01**, then follow the sequence. 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 launcher-backed minimal MCP Apps UI. + MCP server mode: tools, resources, prompts, behavioral annotations, automation visibility, and a minimal MCP Apps UI. ## Run diff --git a/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs b/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs index 462e2b1..4740257 100644 --- a/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs +++ b/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs @@ -30,19 +30,17 @@ public static CommandBuilder WithMcpApp( /// The handler return value should be a complete HTML document. /// /// Command builder. - /// Optional resource metadata configuration. /// 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, - Action? configure = null, McpAppVisibility visibility = McpAppVisibility.ModelAndApp, string? preferredDisplayMode = null) { ArgumentNullException.ThrowIfNull(builder); var resourceUri = McpToolNameFlattener.BuildResourceUri(builder.Route, "ui"); - return builder.AsMcpAppResource(resourceUri, configure, visibility, preferredDisplayMode); + return builder.AsMcpAppResource(resourceUri, visibility, preferredDisplayMode); } /// @@ -51,14 +49,12 @@ public static CommandBuilder AsMcpAppResource( /// /// Command builder. /// The ui:// resource URI. - /// Optional resource metadata configuration. /// 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, - Action? configure = null, McpAppVisibility visibility = McpAppVisibility.ModelAndApp, string? preferredDisplayMode = null) { @@ -66,18 +62,111 @@ public static CommandBuilder AsMcpAppResource( McpAppValidation.ThrowIfInvalidUiUri(resourceUri); var options = new McpAppResourceOptions(); - configure?.Invoke(options); - options.Name ??= resourceUri; options.PreferredDisplayMode ??= preferredDisplayMode; builder .ReadOnly() .AsResource() - .WithMcpApp(resourceUri, visibility) .WithMetadata( McpAppMetadata.ResourceMetadataKey, - new McpAppCommandResourceOptions(resourceUri, options)); + 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, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var permissions = GetResourceOptions(builder).Permissions ?? new McpAppPermissions(); + configure(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 index 510fc19..d63f44f 100644 --- a/src/Repl.Mcp/McpAppCommandResourceOptions.cs +++ b/src/Repl.Mcp/McpAppCommandResourceOptions.cs @@ -2,4 +2,5 @@ namespace Repl.Mcp; internal sealed record McpAppCommandResourceOptions( string ResourceUri, - McpAppResourceOptions ResourceOptions); + McpAppResourceOptions ResourceOptions, + McpAppVisibility Visibility); diff --git a/src/Repl.Mcp/McpAppResourceOptions.cs b/src/Repl.Mcp/McpAppResourceOptions.cs index 013858a..d3510ea 100644 --- a/src/Repl.Mcp/McpAppResourceOptions.cs +++ b/src/Repl.Mcp/McpAppResourceOptions.cs @@ -42,6 +42,12 @@ public sealed class McpAppResourceOptions /// 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. diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 829fdc5..c6d9012 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -706,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); } @@ -760,6 +766,34 @@ private static void AddTool( 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 = McpToolNameFlattener.Flatten(command.Path, separator); + if (nameSet.TryGetValue(toolName, out var existingPath)) + { + if (string.Equals(command.Path, existingPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new InvalidOperationException( + $"MCP tool name collision: '{toolName}' from routes '{existingPath}' and '{command.Path}'. " + + "Consider a different ToolNamingSeparator or rename one of the commands."); + } + + nameSet[toolName] = command.Path; + adapter.RegisterStaticResult( + toolName, + ReplMcpAppLauncherTool.BuildFallbackTextCore(command, appResourceOptions)); + tools.Add(new ReplMcpAppLauncherTool(command, toolName, appResourceOptions)); + } + // ── Resource generation ──────────────────────────────────────────── private List GenerateResources( diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index a3bfa4d..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}"); diff --git a/src/Repl.Mcp/README.md b/src/Repl.Mcp/README.md index 0bc4fa6..21a67a4 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -21,23 +21,18 @@ myapp # still a CLI / interactive REPL Repl.Mcp can also expose MCP Apps UI resources: -```csharp -app.Map("contacts dashboard", () => "Opening the contacts dashboard.") - .WithDescription("Open the contacts dashboard") - .ReadOnly() - .WithMcpApp("ui://contacts/dashboard"); +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. -app.Map("contacts dashboard app", (ContactStore contacts) => +```csharp +app.Map("contacts dashboard", (ContactStore contacts) => $"{contacts.All.Count} contacts") - .WithDescription("Render the contacts dashboard app") - .AsMcpAppResource("ui://contacts/dashboard", resource => - { - resource.Name = "Contacts Dashboard"; - resource.PrefersBorder = true; - }, visibility: McpAppVisibility.App, preferredDisplayMode: McpAppDisplayModes.Fullscreen); + .WithDescription("Open the contacts dashboard") + .AsMcpAppResource() + .WithMcpAppBorder() + .WithMcpAppDisplayMode(McpAppDisplayModes.Fullscreen); ``` -Clients with MCP Apps support render the `ui://` resource. Other MCP clients still receive the launcher tool's normal text result. +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 @@ -47,10 +42,9 @@ Clients with MCP Apps support render the `ui://` resource. Other MCP clients sti | `.Destructive()` | `destructiveHint` — ask for confirmation | | `.AsResource()` | MCP resource with `repl://` URI | | `.AsMcpAppResource()` | MCP Apps HTML resource with `ui://` URI | -| `.AsMcpAppResource(visibility: McpAppVisibility.App)` | MCP Apps tool hidden from the model | -| `.AsMcpAppResource(preferredDisplayMode: McpAppDisplayModes.Fullscreen)` | MCP Apps display preference | +| `.WithMcpAppBorder()` | MCP Apps border/background preference | +| `.WithMcpAppDisplayMode(McpAppDisplayModes.Fullscreen)` | MCP Apps display preference | | `.AsPrompt()` | MCP prompt template | -| `.WithMcpApp("ui://...")` | MCP Apps UI resource link | | `.AutomationHidden()` | Not visible to agents | | `{id:guid}` | `{ "type": "string", "format": "uuid" }` | | `[Description("...")]` | Schema `description` field | 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/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/ReplMcpServerUiResource.cs b/src/Repl.Mcp/ReplMcpServerUiResource.cs index dac5543..0e5f543 100644 --- a/src/Repl.Mcp/ReplMcpServerUiResource.cs +++ b/src/Repl.Mcp/ReplMcpServerUiResource.cs @@ -27,7 +27,7 @@ public ReplMcpServerUiResource( _options = options; _protocolResourceTemplate = new ResourceTemplate { - Name = options.ResourceOptions.Name ?? resourceName, + Name = options.ResourceOptions.Name ?? BuildDefaultResourceName(command.Path), Description = options.ResourceOptions.Description ?? command.Description, UriTemplate = options.ResourceUri, MimeType = McpAppValidation.ResourceMimeType, @@ -64,7 +64,8 @@ public override async ValueTask ReadAsync( arguments, request.Server, progressToken: null, - cancellationToken) + cancellationToken, + allowStaticResults: false) .ConfigureAwait(false); if (result.IsError == true) @@ -175,4 +176,13 @@ private static string UnwrapJsonString(string text) 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 index feb6755..ad5c0fd 100644 --- a/src/Repl.McpTests/Given_McpApps.cs +++ b/src/Repl.McpTests/Given_McpApps.cs @@ -113,11 +113,8 @@ public async Task When_CommandIsMcpAppResource_Then_ResourceReadUsesInjectedServ app.Map("contacts dashboard", (DashboardService service) => $"{service.Title}") .WithDescription("Open dashboard") - .AsMcpAppResource(resource => - { - resource.Name = "Contacts Dashboard"; - resource.PrefersBorder = true; - }); + .AsMcpAppResource() + .WithMcpAppBorder(); }, configureServices: services => { @@ -152,15 +149,14 @@ public void When_CommandIsAppOnlyMcpAppResource_Then_ToolVisibilityIsApp() } [TestMethod] - [Description("AsMcpAppResource can add preferred display metadata for hosts that support it.")] + [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, - preferredDisplayMode: McpAppDisplayModes.Fullscreen); + .AsMcpAppResource(visibility: McpAppVisibility.App) + .WithMcpAppDisplayMode(McpAppDisplayModes.Fullscreen); }).ConfigureAwait(false); var result = await fixture.Client.ReadResourceAsync("ui://contacts/dashboard").ConfigureAwait(false); @@ -171,16 +167,14 @@ public async Task When_CommandHasPreferredDisplayMode_Then_ResourceMetaContainsD } [TestMethod] - [Description("MCP App resource options can include host-specific UI metadata.")] + [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(resource => - { - resource.UiMetadata["presentation"] = "flyout"; - }); + .AsMcpAppResource() + .WithMcpAppUiMetadata("presentation", "flyout"); }).ConfigureAwait(false); var result = await fixture.Client.ReadResourceAsync("ui://contacts/dashboard").ConfigureAwait(false); @@ -191,41 +185,51 @@ public async Task When_CommandHasCustomUiMetadata_Then_ResourceMetaIncludesIt() } [TestMethod] - [Description("A model-visible launcher tool can point at an app-only HTML resource command.")] - public async Task When_ModelLauncherUsesAppOnlyResource_Then_ModelToolDoesNotReturnHtml() + [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", () => "Opening the contacts dashboard.") - .ReadOnly() - .WithMcpApp("ui://contacts/dashboard"); - - app.Map("contacts dashboard app", () => "Contacts") - .AsMcpAppResource("ui://contacts/dashboard", visibility: McpAppVisibility.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 appOnly = tools.Single(tool => - string.Equals(tool.Name, "contacts_dashboard_app", StringComparison.Ordinal)); var launcherUi = launcher.ProtocolTool.Meta!["ui"]!.AsObject(); - var appOnlyUi = appOnly.ProtocolTool.Meta!["ui"]!.AsObject(); launcherUi["visibility"]!.AsArray().Select(static node => node!.GetValue()) .Should().BeEquivalentTo(["model", "app"]); - appOnlyUi["visibility"]!.AsArray().Select(static node => node!.GetValue()) - .Should().ContainSingle("app"); var toolResult = await fixture.Client.CallToolAsync("contacts_dashboard").ConfigureAwait(false); toolResult.Content.OfType().Single().Text - .Should().Contain("Opening the contacts dashboard."); + .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() From 202385b2c764cf8c762f5b7a86c95dff7917fe52 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 7 Apr 2026 00:23:13 -0400 Subject: [PATCH 5/5] Address MCP Apps review findings --- samples/08-mcp-server/Program.cs | 4 +- .../McpAppCommandBuilderExtensions.cs | 6 +-- src/Repl.Mcp/McpAppResourceInvoker.cs | 3 +- src/Repl.Mcp/McpServerHandler.cs | 38 +++++++++++-------- src/Repl.Mcp/ReplMcpServerUiResource.cs | 11 +++--- src/Repl.McpTests/Given_McpApps.cs | 19 ++++++++++ 6 files changed, 53 insertions(+), 28 deletions(-) diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs index 8aa4bbc..36d4f84 100644 --- a/samples/08-mcp-server/Program.cs +++ b/samples/08-mcp-server/Program.cs @@ -130,5 +130,7 @@ internal sealed class ContactStore public IReadOnlyList All => _contacts; - public Contact Get(int id) => id == 1 ? _contacts[0] : _contacts[1]; + public Contact? Get(int id) => id >= 1 && id <= _contacts.Length + ? _contacts[id - 1] + : null; } diff --git a/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs b/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs index 4740257..eba1438 100644 --- a/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs +++ b/src/Repl.Mcp/McpAppCommandBuilderExtensions.cs @@ -131,11 +131,9 @@ public static CommandBuilder WithMcpAppDomain(this CommandBuilder builder, strin /// public static CommandBuilder WithMcpAppPermissions( this CommandBuilder builder, - Action configure) + McpAppPermissions permissions) { - ArgumentNullException.ThrowIfNull(configure); - var permissions = GetResourceOptions(builder).Permissions ?? new McpAppPermissions(); - configure(permissions); + ArgumentNullException.ThrowIfNull(permissions); GetResourceOptions(builder).Permissions = permissions; return builder; } diff --git a/src/Repl.Mcp/McpAppResourceInvoker.cs b/src/Repl.Mcp/McpAppResourceInvoker.cs index 13edd4e..0e7fbb0 100644 --- a/src/Repl.Mcp/McpAppResourceInvoker.cs +++ b/src/Repl.Mcp/McpAppResourceInvoker.cs @@ -21,7 +21,8 @@ public static async ValueTask InvokeAsync( } catch (TargetInvocationException ex) when (ex.InnerException is not null) { - throw ex.InnerException; + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; } return await ConvertResultAsync(result).ConfigureAwait(false); diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index c6d9012..0cdd6c7 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -748,20 +748,12 @@ private static void AddTool( McpToolAdapter adapter, char separator) { - var toolName = McpToolNameFlattener.Flatten(command.Path, separator); - if (nameSet.TryGetValue(toolName, out var existingPath)) + var toolName = TryReserveToolName(command, nameSet, separator); + if (toolName is null) { - if (string.Equals(command.Path, existingPath, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - throw new InvalidOperationException( - $"MCP tool name collision: '{toolName}' from routes '{existingPath}' and '{command.Path}'. " + - "Consider a different ToolNamingSeparator or rename one of the commands."); + return; } - nameSet[toolName] = command.Path; adapter.RegisterRoute(toolName, command); tools.Add(new ReplMcpServerTool(command, toolName, adapter)); } @@ -773,13 +765,30 @@ private static void AddMcpAppLauncherTool( 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( @@ -788,10 +797,7 @@ private static void AddMcpAppLauncherTool( } nameSet[toolName] = command.Path; - adapter.RegisterStaticResult( - toolName, - ReplMcpAppLauncherTool.BuildFallbackTextCore(command, appResourceOptions)); - tools.Add(new ReplMcpAppLauncherTool(command, toolName, appResourceOptions)); + return toolName; } // ── Resource generation ──────────────────────────────────────────── diff --git a/src/Repl.Mcp/ReplMcpServerUiResource.cs b/src/Repl.Mcp/ReplMcpServerUiResource.cs index 0e5f543..8094795 100644 --- a/src/Repl.Mcp/ReplMcpServerUiResource.cs +++ b/src/Repl.Mcp/ReplMcpServerUiResource.cs @@ -106,13 +106,12 @@ private Dictionary ExtractArguments(string uri) return arguments; } - foreach (var name in _variableNames) + foreach (var pair in _variableNames + .Select(name => (Name: name, Group: match.Groups[name])) + .Where(pair => pair.Group.Success)) { - if (match.Groups[name] is { Success: true } group) - { - var value = Uri.UnescapeDataString(group.Value); - arguments[name] = JsonSerializer.SerializeToElement(value, McpJsonContext.Default.String); - } + var value = Uri.UnescapeDataString(pair.Group.Value); + arguments[pair.Name] = JsonSerializer.SerializeToElement(value, McpJsonContext.Default.String); } return arguments; diff --git a/src/Repl.McpTests/Given_McpApps.cs b/src/Repl.McpTests/Given_McpApps.cs index ad5c0fd..d1cb35c 100644 --- a/src/Repl.McpTests/Given_McpApps.cs +++ b/src/Repl.McpTests/Given_McpApps.cs @@ -184,6 +184,25 @@ public async Task When_CommandHasCustomUiMetadata_Then_ResourceMetaIncludesIt() 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()