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("