diff --git a/Samples/Deprecated.Controllers/Deprecated.Controllers.csproj b/Samples/Deprecated.Controllers/Deprecated.Controllers.csproj index 9a269eea..d2c63d5a 100644 --- a/Samples/Deprecated.Controllers/Deprecated.Controllers.csproj +++ b/Samples/Deprecated.Controllers/Deprecated.Controllers.csproj @@ -9,7 +9,8 @@ - + + diff --git a/Samples/Samples.Dialogs/Samples.Dialogs.csproj b/Samples/Samples.Dialogs/Samples.Dialogs.csproj index 90bea372..36e90424 100644 --- a/Samples/Samples.Dialogs/Samples.Dialogs.csproj +++ b/Samples/Samples.Dialogs/Samples.Dialogs.csproj @@ -21,7 +21,8 @@ - + + diff --git a/Samples/Samples.Echo/Samples.Echo.csproj b/Samples/Samples.Echo/Samples.Echo.csproj index 8c396d7b..3747a677 100644 --- a/Samples/Samples.Echo/Samples.Echo.csproj +++ b/Samples/Samples.Echo/Samples.Echo.csproj @@ -7,7 +7,7 @@ - + diff --git a/Samples/Samples.Quoting/Samples.Quoting.csproj b/Samples/Samples.Quoting/Samples.Quoting.csproj index 8c396d7b..3747a677 100644 --- a/Samples/Samples.Quoting/Samples.Quoting.csproj +++ b/Samples/Samples.Quoting/Samples.Quoting.csproj @@ -7,7 +7,7 @@ - + diff --git a/Samples/Samples.SuggestedAction/Samples.SuggestedAction.csproj b/Samples/Samples.SuggestedAction/Samples.SuggestedAction.csproj index 8c396d7b..3747a677 100644 --- a/Samples/Samples.SuggestedAction/Samples.SuggestedAction.csproj +++ b/Samples/Samples.SuggestedAction/Samples.SuggestedAction.csproj @@ -7,7 +7,7 @@ - + diff --git a/Samples/Samples.TargetedMessages/Samples.TargetedMessages.csproj b/Samples/Samples.TargetedMessages/Samples.TargetedMessages.csproj index d6390cbb..16a70e6e 100644 --- a/Samples/Samples.TargetedMessages/Samples.TargetedMessages.csproj +++ b/Samples/Samples.TargetedMessages/Samples.TargetedMessages.csproj @@ -8,7 +8,7 @@ - + diff --git a/core/core.slnx b/core/core.slnx index 5bd7088e..1adeb2fb 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -19,6 +19,7 @@ + diff --git a/core/samples/HtmlWidgetBot/HtmlWidgetBot.csproj b/core/samples/HtmlWidgetBot/HtmlWidgetBot.csproj new file mode 100644 index 00000000..a718b9f3 --- /dev/null +++ b/core/samples/HtmlWidgetBot/HtmlWidgetBot.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + $(NoWarn);ExperimentalTeamsHtmlWidget + + + + + + + diff --git a/core/samples/HtmlWidgetBot/Program.cs b/core/samples/HtmlWidgetBot/Program.cs new file mode 100644 index 00000000..773f5cc2 --- /dev/null +++ b/core/samples/HtmlWidgetBot/Program.cs @@ -0,0 +1,370 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.HtmlWidget; +using Microsoft.Teams.Apps.Schema; + +using HtmlWidgetBot; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication teamsApp = webApp.UseTeamsBotApplication(); + +// ==================== MESSAGE COMMANDS ==================== + +teamsApp.OnMessage(async (context, cancellationToken) => +{ + var text = context.Activity.Text?.Trim().ToLowerInvariant() ?? ""; + + switch (text) + { + case "/simple": + { + var message = HtmlWidgetHelpers.BuildHtmlWidgetMessage( + new HtmlWidgetPayload + { + Name = "Simple Widget", + Description = "A static HTML widget with no callbacks.", + Html = Widgets.SimpleHtml, + Domain = "https://teams.microsoft.com", + SecurityPolicy = new HtmlWidgetSecurityPolicy + { + ConnectDomains = [], + ResourceDomains = ["'self'", "data:"], + FrameDomains = [], + BaseUriDomains = [], + }, + }, + new HtmlWidgetMarkdownOptions + { + Before = "Here is a simple static widget:", + After = "No callbacks needed for static content.", + }); + await context.SendActivityAsync(message, cancellationToken); + break; + } + + case "/calltool": + { + var message = HtmlWidgetHelpers.BuildHtmlWidgetMessage( + new HtmlWidgetPayload + { + Name = "CallTool Widget", + Description = "Widget that calls tools on the bot.", + Html = Widgets.CallToolHtml, + Domain = "https://teams.microsoft.com", + SecurityPolicy = new HtmlWidgetSecurityPolicy + { + ConnectDomains = ["https://teams.microsoft.com", "https://teams.cloud.microsoft.com"], + ResourceDomains = ["'self'", "data:"], + FrameDomains = [], + BaseUriDomains = [], + }, + ToolInput = new { demo = true }, + ToolOutput = new + { + content = new[] { new { type = "text", text = "Initial data loaded." } }, + structuredContent = new { counter = 0, lastAction = "init" }, + isError = false, + }, + }, + new HtmlWidgetMarkdownOptions + { + Before = "Here is a widget with callTool support (click Refresh):", + }); + await context.SendActivityAsync(message, cancellationToken); + break; + } + + case "/messageback": + { + var message = HtmlWidgetHelpers.BuildHtmlWidgetMessage( + new HtmlWidgetPayload + { + Name = "MessageBack Widget", + Description = "Widget that sends messageBack to the bot.", + Html = Widgets.MessageBackHtml, + Domain = "https://teams.microsoft.com", + SecurityPolicy = new HtmlWidgetSecurityPolicy + { + ConnectDomains = [], + ResourceDomains = ["'self'", "data:"], + FrameDomains = [], + BaseUriDomains = [], + }, + }, + new HtmlWidgetMarkdownOptions + { + Before = "This widget tests the onMessage (messageBack) callback:", + }); + await context.SendActivityAsync(message, cancellationToken); + break; + } + + case "/fullscreen": + { + var message = HtmlWidgetHelpers.BuildHtmlWidgetMessage( + new HtmlWidgetPayload + { + Name = "Fullscreen Widget", + Description = "Widget that requests fullscreen mode.", + Html = Widgets.FullscreenHtml, + Domain = "https://teams.microsoft.com", + SecurityPolicy = new HtmlWidgetSecurityPolicy + { + ConnectDomains = [], + ResourceDomains = ["'self'", "data:"], + FrameDomains = [], + BaseUriDomains = [], + }, + }, + new HtmlWidgetMarkdownOptions + { + Before = "This widget will request fullscreen mode:", + }); + await context.SendActivityAsync(message, cancellationToken); + break; + } + + case "/multi": + { + var message = HtmlWidgetHelpers.BuildHtmlWidgetMessage( + new HtmlWidgetPayload + { + Name = "Multi-Tool Widget", + Description = "Widget that calls multiple different tools.", + Html = Widgets.MultiToolHtml, + Domain = "https://teams.microsoft.com", + SecurityPolicy = new HtmlWidgetSecurityPolicy + { + ConnectDomains = ["https://teams.microsoft.com"], + ResourceDomains = ["'self'", "data:"], + FrameDomains = [], + BaseUriDomains = [], + }, + ToolInput = new { }, + ToolOutput = new + { + content = new[] { new { type = "text", text = "Ready." } }, + structuredContent = new { tools = new[] { "getTime", "roll", "echo" } }, + isError = false, + }, + }, + new HtmlWidgetMarkdownOptions + { + Before = "This widget has multiple tools to test dispatch:", + }); + await context.SendActivityAsync(message, cancellationToken); + break; + } + + case "/openlink": + { + var message = HtmlWidgetHelpers.BuildHtmlWidgetMessage( + new HtmlWidgetPayload + { + Name = "open-link-test", + Html = Widgets.OpenLinkHtml, + Domain = "https://teams.microsoft.com", + }, + new HtmlWidgetMarkdownOptions + { + Before = "Widget with ui/open-link support (click a button to open a URL):", + }); + await context.SendActivityAsync(message, cancellationToken); + break; + } + + case "/context": + { + var message = HtmlWidgetHelpers.BuildHtmlWidgetMessage( + new HtmlWidgetPayload + { + Name = "update-context-test", + Html = Widgets.UpdateContextHtml, + Domain = "https://teams.microsoft.com", + }, + new HtmlWidgetMarkdownOptions + { + Before = "Widget with ui/update-model-context support:", + }); + await context.SendActivityAsync(message, cancellationToken); + break; + } + + case "/hostcontext": + { + var message = HtmlWidgetHelpers.BuildHtmlWidgetMessage( + new HtmlWidgetPayload + { + Name = "host-context-inspector", + Html = Widgets.HostContextHtml, + Domain = "https://teams.microsoft.com", + }, + new HtmlWidgetMarkdownOptions + { + Before = "Widget that inspects hostContext from ui/initialize:", + }); + await context.SendActivityAsync(message, cancellationToken); + break; + } + + case "/validate": + { + var htmlWithExternalRefs = """ + +
+

Validation Demo

+

This widget was validated before sending.

+
+ """; + + var strictPolicy = new HtmlWidgetSecurityPolicy + { + ConnectDomains = [], + ResourceDomains = ["'self'", "data:"], + FrameDomains = [], + BaseUriDomains = [], + }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(htmlWithExternalRefs, strictPolicy); + + var correctedPolicy = new HtmlWidgetSecurityPolicy + { + ConnectDomains = [], + ResourceDomains = ["'self'", "data:", "https://fonts.googleapis.com"], + FrameDomains = [], + BaseUriDomains = [], + }; + var warningText = string.Join("\n", warnings.Select(w => $"- **{w.Source}**: `{w.Url}` not in `{w.PolicyField}`")); + var markdown = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown( + new HtmlWidgetPayload + { + Name = "Validated Widget", + Description = "Widget built after security policy validation.", + Html = htmlWithExternalRefs, + Domain = "https://teams.microsoft.com", + SecurityPolicy = correctedPolicy, + }, + new HtmlWidgetMarkdownOptions + { + Before = $"**Validation found {warnings.Count} warning(s):**\n\n{warningText}\n\nPolicy was corrected before sending:", + }); + await context.SendActivityAsync( + new MessageActivity(markdown) { TextFormat = TextFormats.ExtendedMarkdown }, + cancellationToken); + break; + } + + case "/help": + { + await context.SendActivityAsync( + new MessageActivity( + "**HTML Widget Test Commands:**\n\n" + + "- `/simple` - Static widget (no callbacks)\n" + + "- `/calltool` - Widget with onCallTool\n" + + "- `/messageback` - Widget with onMessage\n" + + "- `/fullscreen` - Widget requesting fullscreen\n" + + "- `/multi` - Widget with multiple tools\n" + + "- `/openlink` - Widget with ui/open-link\n" + + "- `/context` - Widget with ui/update-model-context\n" + + "- `/hostcontext` - Inspect hostContext from initialize\n" + + "- `/validate` - Security policy validation demo\n" + + "- `/help` - This message") + { TextFormat = TextFormats.Markdown }, + cancellationToken); + break; + } + + default: + { + await context.SendActivityAsync( + "Send `/help` for available widget test commands.", + cancellationToken); + + break; + } + } +}); + +// ==================== WIDGET CALL TOOL HANDLER ==================== + +teamsApp.OnWidgetCallTool(async (context, cancellationToken) => +{ + var request = context.Activity.Value; + var toolName = request?.Name ?? "unknown"; + var args = request?.Arguments; + + Console.WriteLine($"[widget.callTool] tool={toolName} args={JsonSerializer.Serialize(args)}"); + + var response = toolName switch + { + "refresh" => new HtmlWidgetCallToolResponse + { + CallToolResult = new McpUiCallToolResult + { + Content = [new McpUiCallToolResultContent { Text = "Refreshed!" }], + StructuredContent = new + { + counter = GetCounter(args) + 1, + lastAction = "refresh", + timestamp = DateTime.UtcNow.ToString("o"), + }, + } + }, + "getTime" => new HtmlWidgetCallToolResponse + { + CallToolResult = new McpUiCallToolResult + { + Content = [new McpUiCallToolResultContent { Text = DateTime.UtcNow.ToString("HH:mm:ss") }], + StructuredContent = new { time = DateTime.UtcNow.ToString("o") }, + } + }, + "roll" => CreateRollResponse(args), + "echo" => new HtmlWidgetCallToolResponse + { + CallToolResult = new McpUiCallToolResult + { + Content = [new McpUiCallToolResultContent { Text = JsonSerializer.Serialize(args) }], + StructuredContent = args, + } + }, + _ => HtmlWidgetCallToolResponse.FromError($"Unknown tool: {toolName}"), + }; + + Console.WriteLine($"[widget.callTool] result={JsonSerializer.Serialize(response)}"); + + await Task.CompletedTask; + return InvokeResponse.Ok(response); +}); + +webApp.Run(); + +static int GetCounter(object? args) +{ + if (args is JsonElement je && je.TryGetProperty("counter", out var c) && c.TryGetInt32(out var val)) + return val; + return 0; +} + +static HtmlWidgetCallToolResponse CreateRollResponse(object? args) +{ + int sides = 6; + if (args is JsonElement je && je.TryGetProperty("sides", out var s) && s.TryGetInt32(out var val)) + sides = val; + + int result = Random.Shared.Next(1, sides + 1); + return new HtmlWidgetCallToolResponse + { + CallToolResult = new McpUiCallToolResult + { + Content = [new McpUiCallToolResultContent { Text = $"Rolled a {result} (d{sides})" }], + StructuredContent = new { result, sides }, + } + }; +} diff --git a/core/samples/HtmlWidgetBot/Widgets.cs b/core/samples/HtmlWidgetBot/Widgets.cs new file mode 100644 index 00000000..16e43a8b --- /dev/null +++ b/core/samples/HtmlWidgetBot/Widgets.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HtmlWidgetBot; + +/// +/// HTML widget strings matching the TS/PY examples exactly. +/// These are browser-side JavaScript -- language-agnostic across all SDKs. +/// +public static class Widgets +{ + public const string SimpleHtml = """ +
+

Hello from HTML Widget!

+

This is a static widget with no callbacks. It demonstrates basic HTML rendering in Teams.

+
+ Rendered at: +
+ +
+ """; + + public const string CallToolHtml = """ +
+

CallTool Widget

+
Ready
+ +

+          
+        
+ """; + + public const string MessageBackHtml = """ +
+

MessageBack Widget

+

Click a button to send a messageBack to the bot:

+ + + +
+ """; + + public const string FullscreenHtml = """ +
+

Fullscreen Widget

+

Current mode: inline

+ + + +
+ """; + + public const string MultiToolHtml = """ +
+

Multi-Tool Widget

+
+ + + +
+

+          
+        
+ """; + + public const string OpenLinkHtml = """ +
+

Open Link Widget

+ + + +
+ """; + + public const string UpdateContextHtml = """ +
+

Update Context Widget

+ + +
+ +
+ """; + + public const string HostContextHtml = """ +
+

Host Context Inspector

+
Waiting for ui/initialize...
+ +
+ """; +} diff --git a/core/samples/HtmlWidgetBot/appsettings.json b/core/samples/HtmlWidgetBot/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/core/samples/HtmlWidgetBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/HtmlWidgetCallToolHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/HtmlWidgetCallToolHandler.cs new file mode 100644 index 00000000..ed0c6dd7 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/HtmlWidgetCallToolHandler.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.HtmlWidget; +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling htmlwidget/calltool invoke activities. +/// Sent when a widget calls a tool on the bot via the MCP Apps protocol. +/// +/// The context for the invoke activity with a strongly-typed value. +/// A cancellation token that can be used to cancel the operation. +/// A task that represents the asynchronous operation. The task result contains the invoke response. +[Experimental("ExperimentalTeamsHtmlWidget")] +public delegate Task HtmlWidgetCallToolHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering HTML widget call tool invoke handlers. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public static class HtmlWidgetCallToolExtensions +{ + /// + /// Registers a handler for htmlwidget/calltool invoke activities. + /// Triggered when a widget calls a tool on the bot. + /// Cannot be combined with . + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnWidgetCallTool(this TeamsBotApplication app, HtmlWidgetCallToolHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityTypes.Invoke, InvokeNames.HtmlWidgetCallTool), + Selector = activity => activity.Name == InvokeNames.HtmlWidgetCallTool, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + var typedContext = ctx.CreateDerivedContext(typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs index 92f71f46..b4509d51 100644 --- a/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs +++ b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -184,10 +185,53 @@ public static class InvokeNames /// public const string MessageSubmitAction = "message/submitAction"; + /// + /// HTML widget call tool invoke name. + /// + [Experimental("ExperimentalTeamsHtmlWidget")] + public const string HtmlWidgetCallTool = "htmlwidget/calltool"; + /// /// Suggested action submit invoke name. /// Sent when the user clicks a suggested action of type Action.Submit. /// - [System.Diagnostics.CodeAnalysis.Experimental("ExperimentalTeamsSuggestedAction")] + [Experimental("ExperimentalTeamsSuggestedAction")] public const string SuggestedActionSubmit = "suggestedActions/submit"; + + //TODO : review + /* + /// + /// Execute action invoke name. + /// + public const string ExecuteAction = "actionableMessage/executeAction"; + + /// + /// Handoff invoke name. + /// + public const string Handoff = "handoff/action"; + + /// + /// Search invoke name. + /// + public const string Search = "search"; + /// + /// Config fetch invoke name. + /// + public const string ConfigFetch = "config/fetch"; + + /// + /// Config submit invoke name. + /// + public const string ConfigSubmit = "config/submit"; + + /// + /// Message extension card button clicked invoke name. + /// + public const string MessageExtensionCardButtonClicked = "composeExtension/onCardButtonClicked"; + + /// + /// Message extension setting invoke name. + /// + public const string MessageExtensionSetting = "composeExtension/setting"; + */ } diff --git a/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolRequest.cs b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolRequest.cs new file mode 100644 index 00000000..5a7a2393 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolRequest.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.HtmlWidget; + +/// +/// A request from a widget to call a tool on the bot. +/// Sent as the value of an htmlwidget/calltool invoke activity. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class CallToolRequest +{ + /// + /// The name of the tool to call. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// The arguments to pass to the tool. + /// + [JsonPropertyName("arguments")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Arguments { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs new file mode 100644 index 00000000..4ae49311 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.HtmlWidget; + +/// +/// A content item in an MCP UI call tool result. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class McpUiCallToolResultContent +{ + /// + /// The type of content. MCP defines: "text", "image", "audio", "resource". + /// Teams currently only renders "text" content. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "text"; + + /// + /// The text content. + /// + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; +} + +/// +/// The result of a widget's tools/call request, returned by the bot +/// in response to an htmlwidget/calltool invoke activity. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class McpUiCallToolResult +{ + /// + /// An array of content items to return to the widget. + /// + [JsonPropertyName("content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Content { get; set; } + + /// + /// Structured data that the widget can render from. + /// + [JsonPropertyName("structuredContent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? StructuredContent { get; set; } + + /// + /// Whether the tool call resulted in an error. + /// + [JsonPropertyName("isError")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool IsError { get; set; } +} + +/// +/// The wire-format response body for an htmlwidget/calltool invoke. +/// Teams expects this shape (with responseType discriminator) rather than +/// a bare . +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class HtmlWidgetCallToolResponse +{ + /// + /// Discriminator that tells Teams how to interpret the response. + /// + [JsonPropertyName("responseType")] + public string ResponseType { get; set; } = "htmlwidget/calltoolresult"; + + /// + /// The tool call result payload. + /// + [JsonPropertyName("callToolResult")] + public McpUiCallToolResult CallToolResult { get; set; } = new(); + + /// + /// Creates a successful response with text content. + /// + /// The text to return to the widget. + /// A new . + public static HtmlWidgetCallToolResponse FromText(string text) + { + return new HtmlWidgetCallToolResponse + { + CallToolResult = new McpUiCallToolResult + { + Content = [new McpUiCallToolResultContent { Type = "text", Text = text }] + } + }; + } + + /// + /// Creates an error response with a message. + /// + /// The error message. + /// A new with IsError set. + public static HtmlWidgetCallToolResponse FromError(string message) + { + return new HtmlWidgetCallToolResponse + { + CallToolResult = new McpUiCallToolResult + { + Content = [new McpUiCallToolResultContent { Type = "text", Text = message }], + IsError = true + } + }; + } +} diff --git a/core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetHelpers.cs b/core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetHelpers.cs new file mode 100644 index 00000000..c69e75d0 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetHelpers.cs @@ -0,0 +1,488 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.HtmlWidget; + +/// +/// Options for injecting the MCP Apps protocol into widget HTML. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class InjectWidgetProtocolOptions +{ + /// + /// The widget app name sent during ui/initialize. + /// + public string Name { get; set; } = "widget"; + + /// + /// The widget app version sent during ui/initialize. + /// + public string Version { get; set; } = "1.0.0"; + + /// + /// Display modes this widget supports. + /// + public IList? AvailableDisplayModes { get; set; } + + /// + /// Host notifications to listen for. + /// Known values: "tool-result", "tool-input", "tool-input-partial", + /// "tool-cancelled", "host-context-changed", "resource-teardown". + /// + public IList? Notifications { get; set; } + + /// + /// When true, injects a CSP violation listener for debugging. + /// + public bool DebugCspViolations { get; set; } +} + +/// +/// Options for building an HTML widget markdown string. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class HtmlWidgetMarkdownOptions +{ + /// + /// Text to include before the widget code block. + /// + public string? Before { get; set; } + + /// + /// Text to include after the widget code block. + /// + public string? After { get; set; } + + /// + /// Options forwarded to when the protocol + /// is auto-injected. The Name field is always set from the payload's Name. + /// + public InjectWidgetProtocolOptions? ProtocolOptions { get; set; } +} + +/// +/// A warning produced by when the widget HTML +/// references an external origin not present in the declared security policy. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class SecurityPolicyWarning +{ + /// + /// The URL or origin found in the HTML. + /// + [SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "May contain relative URLs, fragments, or unparseable values")] + public string Url { get; set; } = string.Empty; + + /// + /// The HTML element or API where the reference was found. + /// + public string Source { get; set; } = string.Empty; + + /// + /// The securityPolicy field that should include this origin. + /// + public string PolicyField { get; set; } = string.Empty; + + /// + /// A human-readable description of the issue. + /// + public string Message { get; set; } = string.Empty; +} + +/// +/// Helper methods for building and validating HTML widget messages. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public static class HtmlWidgetHelpers +{ + private const string McpProtocolVersion = "2026-01-26"; + + private static readonly Dictionary NotificationCallbacks = new() + { + ["tool-result"] = "onToolResult", + ["tool-input"] = "onToolInput", + ["tool-input-partial"] = "onToolInputPartial", + ["tool-cancelled"] = "onToolCancelled", + ["host-context-changed"] = "onHostContextChanged", + ["resource-teardown"] = "onResourceTeardown", + }; + + private static readonly HtmlWidgetSecurityPolicy DefaultSecurityPolicy = new() + { + ConnectDomains = [], + ResourceDomains = ["'self'", "data:"], + FrameDomains = [], + BaseUriDomains = [], + }; + + /// + /// Injects the MCP Apps protocol script into widget HTML. + /// If the HTML already contains the protocol (detected by "ui/initialize"), it is returned unchanged. + /// + /// The raw HTML content for the widget. + /// Optional configuration for the protocol setup. + /// The HTML with the protocol script injected. + public static string InjectWidgetProtocol(string html, InjectWidgetProtocolOptions? options = null) + { + ArgumentNullException.ThrowIfNull(html); + + if (html.Contains("ui/initialize", StringComparison.Ordinal)) + { + return html; + } + + var name = EscapeForInlineScript(options?.Name ?? "widget"); + var version = EscapeForInlineScript(options?.Version ?? "1.0.0"); + + var capsJson = options?.AvailableDisplayModes is { Count: > 0 } modes + ? $"{{availableDisplayModes:{JsonSerializer.Serialize(modes)}}}" + : "{}"; + + var hookLines = new StringBuilder(); + if (options?.Notifications is { Count: > 0 } notifications) + { + foreach (var n in notifications) + { + if (NotificationCallbacks.TryGetValue(n, out var cb)) + { + hookLines.Append(CultureInfo.InvariantCulture, $"if(d.method==='ui/notifications/{n}'&&window.{cb}){{window.{cb}(d.params);}}"); + } + } + } + + var cspDebug = options?.DebugCspViolations == true + ? "document.addEventListener('securitypolicyviolation',function(e){" + + "console.warn('[widget CSP violation]',{" + + "blockedURI:e.blockedURI," + + "violatedDirective:e.violatedDirective," + + "originalPolicy:e.originalPolicy" + + "});});" + : ""; + + var script = ""; + + if (html.Contains("", StringComparison.Ordinal)) + { + return html.Replace("", script + "", StringComparison.Ordinal); + } + + return html + script; + } + + /// + /// Wraps an HTML widget payload in the ```html-widget markdown code fence + /// format required by Teams to render the widget in a message. + /// + /// The widget payload to serialize. + /// Optional text to include before/after the widget block. + /// The markdown string containing the widget code block. + public static string BuildHtmlWidgetMarkdown(HtmlWidgetPayload payload, HtmlWidgetMarkdownOptions? options = null) + { + ArgumentNullException.ThrowIfNull(payload); + ValidatePayload(payload); + + var protocolOpts = new InjectWidgetProtocolOptions + { + Name = payload.Name, + Version = options?.ProtocolOptions?.Version ?? "1.0.0", + AvailableDisplayModes = options?.ProtocolOptions?.AvailableDisplayModes, + Notifications = options?.ProtocolOptions?.Notifications, + DebugCspViolations = options?.ProtocolOptions?.DebugCspViolations ?? false, + }; + + var injectedPayload = new HtmlWidgetPayload + { + Type = payload.Type, + Name = payload.Name, + Description = payload.Description, + Html = InjectWidgetProtocol(payload.Html, protocolOpts), + Domain = payload.Domain, + SecurityPolicy = payload.SecurityPolicy ?? DefaultSecurityPolicy, + ToolInput = payload.ToolInput, + ToolOutput = payload.ToolOutput, + Permissions = payload.Permissions, + }; + + var json = JsonSerializer.Serialize(injectedPayload); + var parts = new List(); + + if (!string.IsNullOrEmpty(options?.Before)) + { + parts.Add(options.Before); + parts.Add(""); + } + + parts.Add("```html-widget"); + parts.Add(json); + parts.Add("```"); + + if (!string.IsNullOrEmpty(options?.After)) + { + parts.Add(""); + parts.Add(options.After); + } + + return string.Join("\n", parts); + } + + /// + /// Builds a message activity containing an HTML widget, ready to be sent. + /// + /// The widget payload to include in the message. + /// Optional text to include before/after the widget block. + /// A MessageActivity with TextFormat set to "extendedmarkdown". + public static MessageActivity BuildHtmlWidgetMessage(HtmlWidgetPayload payload, HtmlWidgetMarkdownOptions? options = null) + { + return new MessageActivity + { + Text = BuildHtmlWidgetMarkdown(payload, options), + TextFormat = TextFormats.ExtendedMarkdown, + }; + } + + /// + /// Validates that external references in widget HTML are covered by the + /// declared security policy. Returns a list of warnings for any + /// references to origins not present in the appropriate policy field. + /// + /// The raw HTML content of the widget. + /// The security policy to validate against. + /// A list of warnings. Empty list means no issues found. + public static IList ValidateSecurityPolicy(string html, HtmlWidgetSecurityPolicy policy) + { + ArgumentNullException.ThrowIfNull(html); + ArgumentNullException.ThrowIfNull(policy); + + var warnings = new List(); + + // resourceDomains: "; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html); + Assert.Equal(html, result); + } + + [Fact] + public void InjectWidgetProtocol_InjectsBeforeBodyClose() + { + var html = "

Hello

"; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html); + Assert.Contains("ui/initialize", result); + Assert.Contains("ui/notifications/size-changed", result); + Assert.EndsWith("", result); + } + + [Fact] + public void InjectWidgetProtocol_AppendsIfNoBody() + { + var html = "
Hello
"; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html); + Assert.Contains("ui/initialize", result); + Assert.StartsWith("
Hello
", result); + } + + [Fact] + public void InjectWidgetProtocol_UsesCustomNameAndVersion() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Name = "myWidget", + Version = "2.0.0" + }); + Assert.Contains("name:'myWidget'", result); + Assert.Contains("version:'2.0.0'", result); + } + + [Fact] + public void InjectWidgetProtocol_InjectsNotificationHooks() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Notifications = ["tool-result", "tool-input"] + }); + Assert.Contains("ui/notifications/tool-result", result); + Assert.Contains("window.onToolResult", result); + Assert.Contains("ui/notifications/tool-input", result); + Assert.Contains("window.onToolInput", result); + } + + [Fact] + public void InjectWidgetProtocol_IgnoresUnknownNotifications() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Notifications = ["unknown-notification"] + }); + Assert.DoesNotContain("unknown-notification", result); + } + + [Fact] + public void InjectWidgetProtocol_InjectsCspDebugListener() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + DebugCspViolations = true + }); + Assert.Contains("securitypolicyviolation", result); + } + + [Fact] + public void InjectWidgetProtocol_IncludesDisplayModes() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + AvailableDisplayModes = ["inline", "fullscreen"] + }); + Assert.Contains("availableDisplayModes", result); + Assert.Contains("inline", result); + Assert.Contains("fullscreen", result); + } + + [Fact] + public void InjectWidgetProtocol_EscapesSpecialChars() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Name = "it's a \"test\\" + }); + Assert.Contains("it\\'s a \"test\\\\", result); + } + + [Fact] + public void InjectWidgetProtocol_EscapesScriptCloseTagInName() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Name = "" + }); + // Only one should exist (the injected protocol's closing tag) + var scriptTagCount = System.Text.RegularExpressions.Regex.Matches(result, "", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Count; + Assert.Equal(1, scriptTagCount); + Assert.Contains("<\\/script>", result); + } + + [Fact] + public void InjectWidgetProtocol_EscapesScriptCloseTagInVersion() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Version = "" + }); + var scriptTagCount = System.Text.RegularExpressions.Regex.Matches(result, "", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Count; + Assert.Equal(1, scriptTagCount); + } + + [Fact] + public void InjectWidgetProtocol_EscapesNewlinesInName() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Name = "line1\nline2\rline3" + }); + var scriptMatch = System.Text.RegularExpressions.Regex.Match(result, @"", System.Text.RegularExpressions.RegexOptions.Singleline); + Assert.True(scriptMatch.Success); + var scriptContent = scriptMatch.Groups[1].Value; + Assert.DoesNotContain("\n", scriptContent); + Assert.Contains("\\n", scriptContent); + Assert.Contains("\\r", scriptContent); + } + + // --- BuildHtmlWidgetMarkdown --- + + [Fact] + public void BuildHtmlWidgetMarkdown_WrapsInCodeFence() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "

Hi

", + Domain = "https://example.com" + }; + var result = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload); + Assert.StartsWith("```html-widget\n", result); + Assert.EndsWith("\n```", result); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_InjectsProtocol() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "

Hi

", + Domain = "https://example.com" + }; + var result = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload); + Assert.Contains("ui/initialize", result); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_AppliesDefaultSecurityPolicy() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "

Hi

", + Domain = "https://example.com" + }; + var result = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload); + Assert.Contains("securityPolicy", result); + Assert.Contains("resourceDomains", result); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_IncludesBeforeAndAfter() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "

Hi

", + Domain = "https://example.com" + }; + var result = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload, new HtmlWidgetMarkdownOptions + { + Before = "Hello before", + After = "Hello after" + }); + Assert.StartsWith("Hello before\n", result); + Assert.EndsWith("\nHello after", result); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_ThrowsOnEmptyName() + { + var payload = new HtmlWidgetPayload + { + Name = "", + Html = "Hi", + Domain = "https://example.com" + }; + var ex = Assert.Throws(() => HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload)); + Assert.Contains("name", ex.Message); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_ThrowsOnEmptyHtml() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "", + Domain = "https://example.com" + }; + var ex = Assert.Throws(() => HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload)); + Assert.Contains("html", ex.Message); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_ThrowsOnInvalidDomain() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "Hi", + Domain = "http://example.com" + }; + var ex = Assert.Throws(() => HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload)); + Assert.Contains("domain", ex.Message); + } + + // --- BuildHtmlWidgetMessage --- + + [Fact] + public void BuildHtmlWidgetMessage_SetsExtendedMarkdownFormat() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "

Hi

", + Domain = "https://example.com" + }; + var message = HtmlWidgetHelpers.BuildHtmlWidgetMessage(payload); + Assert.Equal(TextFormats.ExtendedMarkdown, message.TextFormat); + Assert.Contains("```html-widget", message.Text); + } + + // --- ValidateSecurityPolicy --- + + [Fact] + public void ValidateSecurityPolicy_DetectsExternalScriptSrc() + { + var html = ""; + var policy = new HtmlWidgetSecurityPolicy { ResourceDomains = [] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Single(warnings); + Assert.Equal(""; + var policy = new HtmlWidgetSecurityPolicy { ResourceDomains = ["https://cdn.example.com"] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Empty(warnings); + } + + [Fact] + public void ValidateSecurityPolicy_DetectsFetch() + { + var html = ""; + var policy = new HtmlWidgetSecurityPolicy { ConnectDomains = [] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Single(warnings); + Assert.Equal("fetch()", warnings[0].Source); + Assert.Equal("connectDomains", warnings[0].PolicyField); + } + + [Fact] + public void ValidateSecurityPolicy_DetectsIframeSrc() + { + var html = ""; + var policy = new HtmlWidgetSecurityPolicy { FrameDomains = [] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Single(warnings); + Assert.Equal(""; + var policy = new HtmlWidgetSecurityPolicy { FrameDomains = ["https://embed.example.com"] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Empty(warnings); + } + + [Fact] + public void ValidateSecurityPolicy_DetectsCssImport() + { + var html = ""; + var policy = new HtmlWidgetSecurityPolicy { ResourceDomains = [] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Single(warnings); + } + + [Fact] + public void ValidateSecurityPolicy_HandlesUndefinedPolicyFields() + { + var html = ""; + var policy = new HtmlWidgetSecurityPolicy(); + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Single(warnings); + } + + [Fact] + public async Task InjectWidgetProtocol_FullScriptSnapshot() + { + var opts = new InjectWidgetProtocolOptions + { + Name = "My Widget", + Version = "2.0.0", + AvailableDisplayModes = ["inline", "fullscreen"], + Notifications = ["tool-result", "tool-input"], + DebugCspViolations = true, + }; + var result = HtmlWidgetHelpers.InjectWidgetProtocol("

Hello

", opts); + + var match = System.Text.RegularExpressions.Regex.Match(result, @""); + Assert.True(match.Success); + await VerifyXunit.Verifier.Verify(match.Groups[1].Value); + } +} + +#pragma warning restore ExperimentalTeamsHtmlWidget diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj b/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj index bd61ffd3..eabfcdbb 100644 --- a/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj +++ b/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj @@ -5,7 +5,7 @@ enable enable false - $(NoWarn);ExperimentalTeamsTargeted + $(NoWarn);ExperimentalTeamsTargeted;ExperimentalTeamsHtmlWidget @@ -15,6 +15,7 @@ + all