From 0d4d85e2429a5a0df4f5f3e59183fe3ba1c2277d Mon Sep 17 00:00:00 2001
From: Corina Gum <14900841+corinagum@users.noreply.github.com>
Date: Wed, 1 Jul 2026 12:38:23 -0700
Subject: [PATCH 1/6] HTML Widgets
---
.../Handlers/HtmlWidgetCallToolHandler.cs | 54 ++
.../Handlers/InvokeHandler.Activity.cs | 46 +-
.../HtmlWidget/CallToolRequest.cs | 28 +
.../HtmlWidget/CallToolResult.cs | 109 ++++
.../HtmlWidget/HtmlWidgetHelpers.cs | 488 ++++++++++++++++++
.../HtmlWidget/HtmlWidgetPayload.cs | 146 ++++++
6 files changed, 870 insertions(+), 1 deletion(-)
create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/HtmlWidgetCallToolHandler.cs
create mode 100644 core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolRequest.cs
create mode 100644 core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs
create mode 100644 core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetHelpers.cs
create mode 100644 core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetPayload.cs
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..27a7bf9a
--- /dev/null
+++ b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs
@@ -0,0 +1,109 @@
+// 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 (e.g. "text").
+ ///
+ [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("