From 47a709a63956e94f3c2b7fe5ee4a3bde9e4347fc Mon Sep 17 00:00:00 2001
From: Corina Gum <14900841+corinagum@users.noreply.github.com>
Date: Mon, 15 Jun 2026 16:48:29 -0700
Subject: [PATCH 01/12] feat(api): add HTML widget types and invoke activity
Add types for the HTML widget contract:
- IHtmlWidgetPayload, IHtmlWidgetSecurityPolicy, IHtmlWidgetPermissions
- ICallToolRequest, IMcpUiCallToolResult
- IHtmlWidgetCallToolInvokeActivity (htmlwidget/calltool invoke)
- Add 'extendedmarkdown' to TextFormat union
- Add htmlwidget/calltool to InvokeResponseBody map
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../invoke/html-widget/call-tool.ts | 14 +++
.../activities/invoke/html-widget/index.ts | 1 +
packages/api/src/activities/invoke/index.ts | 5 +-
.../models/html-widget/call-tool-request.ts | 15 +++
.../models/html-widget/call-tool-result.ts | 35 ++++++
.../models/html-widget/html-widget-payload.ts | 101 ++++++++++++++++++
packages/api/src/models/html-widget/index.ts | 3 +
packages/api/src/models/index.ts | 1 +
packages/api/src/models/invoke-response.ts | 2 +
9 files changed, 176 insertions(+), 1 deletion(-)
create mode 100644 packages/api/src/activities/invoke/html-widget/call-tool.ts
create mode 100644 packages/api/src/activities/invoke/html-widget/index.ts
create mode 100644 packages/api/src/models/html-widget/call-tool-request.ts
create mode 100644 packages/api/src/models/html-widget/call-tool-result.ts
create mode 100644 packages/api/src/models/html-widget/html-widget-payload.ts
create mode 100644 packages/api/src/models/html-widget/index.ts
diff --git a/packages/api/src/activities/invoke/html-widget/call-tool.ts b/packages/api/src/activities/invoke/html-widget/call-tool.ts
new file mode 100644
index 000000000..275be4229
--- /dev/null
+++ b/packages/api/src/activities/invoke/html-widget/call-tool.ts
@@ -0,0 +1,14 @@
+import { ICallToolRequest } from '../../../models';
+import { IActivity } from '../../activity';
+
+export interface IHtmlWidgetCallToolInvokeActivity extends IActivity<'invoke'> {
+ /**
+ * The name of the operation associated with an invoke or event activity.
+ */
+ name: 'htmlwidget/calltool';
+
+ /**
+ * A value that is associated with the activity.
+ */
+ value: ICallToolRequest;
+}
diff --git a/packages/api/src/activities/invoke/html-widget/index.ts b/packages/api/src/activities/invoke/html-widget/index.ts
new file mode 100644
index 000000000..0271fdc65
--- /dev/null
+++ b/packages/api/src/activities/invoke/html-widget/index.ts
@@ -0,0 +1 @@
+export * from './call-tool';
diff --git a/packages/api/src/activities/invoke/index.ts b/packages/api/src/activities/invoke/index.ts
index 8411469c5..d1349163c 100644
--- a/packages/api/src/activities/invoke/index.ts
+++ b/packages/api/src/activities/invoke/index.ts
@@ -3,6 +3,7 @@ import { ConfigInvokeActivity } from './config';
import { IExecuteActionInvokeActivity } from './execute-action';
import { IFileConsentInvokeActivity } from './file-consent';
import { IHandoffActionInvokeActivity } from './handoff-action';
+import { IHtmlWidgetCallToolInvokeActivity } from './html-widget';
import { MessageInvokeActivity } from './message';
import { MessageExtensionInvokeActivity } from './message-extension';
import { SignInInvokeActivity } from './sign-in';
@@ -21,7 +22,8 @@ export type InvokeActivity =
| IHandoffActionInvokeActivity
| SignInInvokeActivity
| AdaptiveCardInvokeActivity
- | ISuggestedActionSubmitInvokeActivity;
+ | ISuggestedActionSubmitInvokeActivity
+ | IHtmlWidgetCallToolInvokeActivity;
export * from './file-consent';
export * from './execute-action';
@@ -34,3 +36,4 @@ export * from './handoff-action';
export * from './sign-in';
export * from './suggested-action-submit';
export * from './adaptive-card';
+export * from './html-widget';
diff --git a/packages/api/src/models/html-widget/call-tool-request.ts b/packages/api/src/models/html-widget/call-tool-request.ts
new file mode 100644
index 000000000..54a79a73b
--- /dev/null
+++ b/packages/api/src/models/html-widget/call-tool-request.ts
@@ -0,0 +1,15 @@
+/**
+ * A request from a widget to call a tool on the bot.
+ * Sent as the value of an `htmlwidget/calltool` invoke activity.
+ */
+export interface ICallToolRequest {
+ /**
+ * The name of the tool to call.
+ */
+ name: string;
+
+ /**
+ * The arguments to pass to the tool.
+ */
+ arguments?: unknown;
+}
diff --git a/packages/api/src/models/html-widget/call-tool-result.ts b/packages/api/src/models/html-widget/call-tool-result.ts
new file mode 100644
index 000000000..c87a802fa
--- /dev/null
+++ b/packages/api/src/models/html-widget/call-tool-result.ts
@@ -0,0 +1,35 @@
+/**
+ * A content item in an MCP UI call tool result.
+ */
+export interface IMcpUiCallToolResultContent {
+ /**
+ * The type of content (e.g. "text").
+ */
+ type: string;
+
+ /**
+ * The text content.
+ */
+ text: string;
+}
+
+/**
+ * The result of a widget's `tools/call` request, returned by the bot
+ * in response to an `htmlwidget/calltool` invoke activity.
+ */
+export interface IMcpUiCallToolResult {
+ /**
+ * An array of content items to return to the widget.
+ */
+ content?: IMcpUiCallToolResultContent[];
+
+ /**
+ * Structured data that the widget can render from.
+ */
+ structuredContent?: unknown;
+
+ /**
+ * Whether the tool call resulted in an error.
+ */
+ isError?: boolean;
+}
diff --git a/packages/api/src/models/html-widget/html-widget-payload.ts b/packages/api/src/models/html-widget/html-widget-payload.ts
new file mode 100644
index 000000000..956281b8c
--- /dev/null
+++ b/packages/api/src/models/html-widget/html-widget-payload.ts
@@ -0,0 +1,101 @@
+/**
+ * The security policy for an HTML widget, controlling allowed origins
+ * for network requests, static resources, nested iframes, and base URIs.
+ */
+export interface IHtmlWidgetSecurityPolicy {
+ /**
+ * Allowed origins for network requests.
+ */
+ connectDomains?: string[];
+
+ /**
+ * Allowed origins for static resources.
+ */
+ resourceDomains?: string[];
+
+ /**
+ * Allowed origins for nested iframes.
+ */
+ frameDomains?: string[];
+
+ /**
+ * Allowed base URIs for the document.
+ */
+ baseUriDomains?: string[];
+}
+
+/**
+ * Permissions that the widget may request from the host.
+ */
+export interface IHtmlWidgetPermissions {
+ /**
+ * Request camera access.
+ */
+ camera?: unknown;
+
+ /**
+ * Request microphone access.
+ */
+ microphone?: unknown;
+
+ /**
+ * Request geolocation access.
+ */
+ geolocation?: unknown;
+
+ /**
+ * Request clipboard write access.
+ */
+ clipboardWrite?: unknown;
+}
+
+/**
+ * The JSON payload for an HTML widget, sent inside a ```html-widget code block
+ * within a Markdown message.
+ */
+export interface IHtmlWidgetPayload {
+ /**
+ * The widget type identifier. Currently only "widget/mcp-ui" is supported.
+ */
+ type: 'widget/mcp-ui';
+
+ /**
+ * The display name of the MCP app.
+ */
+ name: string;
+
+ /**
+ * A description of the MCP app.
+ */
+ description?: string;
+
+ /**
+ * The HTML content that makes up the widget.
+ */
+ html: string;
+
+ /**
+ * The domain associated with the widget.
+ */
+ domain: string;
+
+ /**
+ * Optional security policy controlling allowed origins.
+ */
+ securityPolicy?: IHtmlWidgetSecurityPolicy;
+
+ /**
+ * Optional data that was passed as input to the tool that produced this widget.
+ */
+ toolInput?: unknown;
+
+ /**
+ * Optional data that the tool produced alongside this widget.
+ */
+ toolOutput?: unknown;
+
+ /**
+ * Optional permissions the widget requests from the host.
+ */
+ permissions?: IHtmlWidgetPermissions;
+}
diff --git a/packages/api/src/models/html-widget/index.ts b/packages/api/src/models/html-widget/index.ts
new file mode 100644
index 000000000..ca1e46e3e
--- /dev/null
+++ b/packages/api/src/models/html-widget/index.ts
@@ -0,0 +1,3 @@
+export * from './html-widget-payload';
+export * from './call-tool-request';
+export * from './call-tool-result';
diff --git a/packages/api/src/models/index.ts b/packages/api/src/models/index.ts
index 83dbdface..11346644f 100644
--- a/packages/api/src/models/index.ts
+++ b/packages/api/src/models/index.ts
@@ -35,4 +35,5 @@ export * from './team-details';
export * from './meeting';
export * from './channel-id';
export * from './activity-like';
+export * from './html-widget';
diff --git a/packages/api/src/models/invoke-response.ts b/packages/api/src/models/invoke-response.ts
index 14cbda234..a9cd28178 100644
--- a/packages/api/src/models/invoke-response.ts
+++ b/packages/api/src/models/invoke-response.ts
@@ -1,6 +1,7 @@
import {
AdaptiveCardActionResponse,
ConfigResponse,
+ IMcpUiCallToolResult,
MessagingExtensionActionResponse,
MessagingExtensionResponse,
TabResponse,
@@ -63,4 +64,5 @@ type InvokeResponseBody = {
'signin/verifyState': void;
'signin/failure': void;
'adaptiveCard/action': AdaptiveCardActionResponse;
+ 'htmlwidget/calltool': IMcpUiCallToolResult;
};
From a83930965f6ef4eb226e8e66c041273360d2243d Mon Sep 17 00:00:00 2001
From: Corina Gum <14900841+corinagum@users.noreply.github.com>
Date: Mon, 15 Jun 2026 16:57:08 -0700
Subject: [PATCH 02/12] feat(apps): add widget.callTool invoke route and helper
methods
- Add 'htmlwidget/calltool' -> 'widget.callTool' invoke alias
- Add WidgetCallToolRoutes type for typed handler registration
- Add buildHtmlWidgetMarkdown() and buildHtmlWidgetMessage() helpers
- Add unit tests (14 cases including edge cases)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
packages/apps/src/index.ts | 4 +
packages/apps/src/routes/invoke/index.ts | 7 +-
.../apps/src/routes/invoke/widget-calltool.ts | 11 ++
packages/apps/src/utils/html-widget.spec.ts | 156 ++++++++++++++++++
packages/apps/src/utils/html-widget.ts | 61 +++++++
5 files changed, 238 insertions(+), 1 deletion(-)
create mode 100644 packages/apps/src/routes/invoke/widget-calltool.ts
create mode 100644 packages/apps/src/utils/html-widget.spec.ts
create mode 100644 packages/apps/src/utils/html-widget.ts
diff --git a/packages/apps/src/index.ts b/packages/apps/src/index.ts
index 98918b252..9debe384b 100644
--- a/packages/apps/src/index.ts
+++ b/packages/apps/src/index.ts
@@ -10,3 +10,7 @@ export * from './http';
// Threading utilities
export { toThreadedConversationId } from './utils/thread';
+
+// HTML Widget utilities
+export { buildHtmlWidgetMarkdown, buildHtmlWidgetMessage } from './utils/html-widget';
+export type { IHtmlWidgetMarkdownOptions } from './utils/html-widget';
diff --git a/packages/apps/src/routes/invoke/index.ts b/packages/apps/src/routes/invoke/index.ts
index 0a1db3d50..eb43f2df5 100644
--- a/packages/apps/src/routes/invoke/index.ts
+++ b/packages/apps/src/routes/invoke/index.ts
@@ -9,6 +9,7 @@ import { DialogSubmitSubRoutes } from './dialog-submit';
import { FileConsentActivityRoutes } from './file-consent';
import { MessageExtensionSubmitActivityRoutes } from './message-extension-submit';
import { MessageSubmitActivityRoutes } from './message-submit';
+import { WidgetCallToolRoutes } from './widget-calltool';
export type InvokeActivityRoutes = Record> = {
[K in InvokeActivity['name']as InvokeAliases[K]]?: RouteHandler<
@@ -20,7 +21,8 @@ export type InvokeActivityRoutes = Record<
MessageSubmitActivityRoutes &
DialogOpenSubRoutes &
DialogSubmitSubRoutes &
- CardActionSubRoutes;
+ CardActionSubRoutes &
+ WidgetCallToolRoutes;
type InvokeAliases = {
'config/fetch': 'config.open';
@@ -48,6 +50,7 @@ type InvokeAliases = {
'signin/verifyState': 'signin.verify-state';
'signin/failure': 'signin.failure';
'adaptiveCard/action': 'card.action';
+ 'htmlwidget/calltool': 'widget.callTool';
};
export const INVOKE_ALIASES: InvokeAliases = {
@@ -76,6 +79,7 @@ export const INVOKE_ALIASES: InvokeAliases = {
'signin/verifyState': 'signin.verify-state',
'signin/failure': 'signin.failure',
'adaptiveCard/action': 'card.action',
+ 'htmlwidget/calltool': 'widget.callTool',
};
export * from './card-action';
@@ -84,4 +88,5 @@ export * from './dialog-submit';
export * from './file-consent';
export * from './message-extension-submit';
export * from './message-submit';
+export * from './widget-calltool';
diff --git a/packages/apps/src/routes/invoke/widget-calltool.ts b/packages/apps/src/routes/invoke/widget-calltool.ts
new file mode 100644
index 000000000..7991735ac
--- /dev/null
+++ b/packages/apps/src/routes/invoke/widget-calltool.ts
@@ -0,0 +1,11 @@
+import { IHtmlWidgetCallToolInvokeActivity, InvokeResponse } from '@microsoft/teams.api';
+
+import { IActivityContext } from '../../contexts';
+import { RouteHandler } from '../../types';
+
+export type WidgetCallToolRoutes = Record> = {
+ 'widget.callTool'?: RouteHandler<
+ IActivityContext,
+ InvokeResponse<'htmlwidget/calltool'> | InvokeResponse<'htmlwidget/calltool'>['body']
+ >;
+};
diff --git a/packages/apps/src/utils/html-widget.spec.ts b/packages/apps/src/utils/html-widget.spec.ts
new file mode 100644
index 000000000..cbaebba37
--- /dev/null
+++ b/packages/apps/src/utils/html-widget.spec.ts
@@ -0,0 +1,156 @@
+import { buildHtmlWidgetMarkdown, buildHtmlWidgetMessage } from './html-widget';
+import { IHtmlWidgetPayload } from '@microsoft/teams.api';
+
+const MINIMAL_PAYLOAD: IHtmlWidgetPayload = {
+ type: 'widget/mcp-ui',
+ name: 'Test Widget',
+ html: 'Hello
',
+ domain: 'https://example.com',
+};
+
+const FULL_PAYLOAD: IHtmlWidgetPayload = {
+ type: 'widget/mcp-ui',
+ name: 'Weather Widget',
+ description: 'Current weather conditions',
+ html: '72F
',
+ domain: 'https://weather.example.com',
+ securityPolicy: {
+ connectDomains: ['https://api.example.com'],
+ resourceDomains: ["'self'", 'data:'],
+ frameDomains: [],
+ baseUriDomains: [],
+ },
+ toolInput: { location: 'Seattle, WA' },
+ toolOutput: { content: [{ type: 'text', text: 'Seattle: 72F' }], structuredContent: { tempF: 72 }, isError: false },
+ permissions: { clipboardWrite: {} },
+};
+
+describe('buildHtmlWidgetMarkdown', () => {
+ it('should wrap payload in html-widget code fence', () => {
+ const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD);
+ expect(result).toBe(
+ '```html-widget\n' + JSON.stringify(MINIMAL_PAYLOAD) + '\n```'
+ );
+ });
+
+ it('should include text before the widget', () => {
+ const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, { before: 'Check this out:' });
+ expect(result).toBe(
+ 'Check this out:\n\n```html-widget\n' + JSON.stringify(MINIMAL_PAYLOAD) + '\n```'
+ );
+ });
+
+ it('should include text after the widget', () => {
+ const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, { after: 'Pretty cool, right?' });
+ expect(result).toBe(
+ '```html-widget\n' + JSON.stringify(MINIMAL_PAYLOAD) + '\n```\n\nPretty cool, right?'
+ );
+ });
+
+ it('should include text before and after the widget', () => {
+ const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, {
+ before: 'Before',
+ after: 'After',
+ });
+ expect(result).toBe(
+ 'Before\n\n```html-widget\n' + JSON.stringify(MINIMAL_PAYLOAD) + '\n```\n\nAfter'
+ );
+ });
+
+ it('should serialize a full payload with all fields', () => {
+ const result = buildHtmlWidgetMarkdown(FULL_PAYLOAD);
+ const parsed = JSON.parse(result.replace('```html-widget\n', '').replace('\n```', ''));
+ expect(parsed.type).toBe('widget/mcp-ui');
+ expect(parsed.name).toBe('Weather Widget');
+ expect(parsed.description).toBe('Current weather conditions');
+ expect(parsed.html).toBe('72F
');
+ expect(parsed.domain).toBe('https://weather.example.com');
+ expect(parsed.securityPolicy.connectDomains).toEqual(['https://api.example.com']);
+ expect(parsed.toolInput).toEqual({ location: 'Seattle, WA' });
+ expect(parsed.permissions).toEqual({ clipboardWrite: {} });
+ });
+
+ it('should produce valid markdown with no options', () => {
+ const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD);
+ expect(result.startsWith('```html-widget\n')).toBe(true);
+ expect(result.endsWith('\n```')).toBe(true);
+ });
+
+ it('should handle HTML containing backticks without breaking the fence', () => {
+ const payload: IHtmlWidgetPayload = {
+ ...MINIMAL_PAYLOAD,
+ html: '```some code```',
+ };
+ const result = buildHtmlWidgetMarkdown(payload);
+ // JSON.stringify escapes nothing about backticks, but they appear inside
+ // a JSON string value (quoted), so the code fence boundary is unambiguous
+ // because the fence opener/closer are on their own lines.
+ expect(result.startsWith('```html-widget\n')).toBe(true);
+ expect(result.endsWith('\n```')).toBe(true);
+ const jsonLine = result.split('\n').slice(1, -1).join('\n');
+ const parsed = JSON.parse(jsonLine);
+ expect(parsed.html).toBe('```some code```');
+ });
+
+ it('should handle HTML with newlines and special characters', () => {
+ const payload: IHtmlWidgetPayload = {
+ ...MINIMAL_PAYLOAD,
+ html: '',
+ };
+ const result = buildHtmlWidgetMarkdown(payload);
+ // JSON.stringify will escape the newlines and quotes inside the string
+ const jsonLine = result.split('\n')[1];
+ const parsed = JSON.parse(jsonLine);
+ expect(parsed.html).toBe('');
+ });
+
+ it('should handle empty string options without adding extra lines', () => {
+ const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, { before: '', after: '' });
+ expect(result).toBe(
+ '```html-widget\n' + JSON.stringify(MINIMAL_PAYLOAD) + '\n```'
+ );
+ });
+
+ it('should handle payload with undefined optional fields', () => {
+ const payload: IHtmlWidgetPayload = {
+ type: 'widget/mcp-ui',
+ name: 'Bare',
+ html: '',
+ domain: '',
+ };
+ const result = buildHtmlWidgetMarkdown(payload);
+ const jsonLine = result.split('\n')[1];
+ const parsed = JSON.parse(jsonLine);
+ expect(parsed.type).toBe('widget/mcp-ui');
+ expect(parsed.description).toBeUndefined();
+ expect(parsed.securityPolicy).toBeUndefined();
+ expect(parsed.toolInput).toBeUndefined();
+ expect(parsed.permissions).toBeUndefined();
+ });
+});
+
+describe('buildHtmlWidgetMessage', () => {
+ it('should return a message activity with extendedmarkdown format', () => {
+ const result = buildHtmlWidgetMessage(MINIMAL_PAYLOAD);
+ expect(result.type).toBe('message');
+ expect(result.textFormat).toBe('extendedmarkdown');
+ });
+
+ it('should contain the widget markdown in the text field', () => {
+ const result = buildHtmlWidgetMessage(MINIMAL_PAYLOAD);
+ expect(result.text).toBe(buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD));
+ });
+
+ it('should pass options through to markdown builder', () => {
+ const result = buildHtmlWidgetMessage(FULL_PAYLOAD, { before: 'Weather today:' });
+ expect(result.text).toBe(buildHtmlWidgetMarkdown(FULL_PAYLOAD, { before: 'Weather today:' }));
+ });
+
+ it('should produce a message sendable as ActivityLike', () => {
+ const result = buildHtmlWidgetMessage(MINIMAL_PAYLOAD);
+ // ActivityLike requires at minimum a `type` field
+ expect(result).toHaveProperty('type');
+ expect(result).toHaveProperty('text');
+ expect(result).toHaveProperty('textFormat');
+ });
+});
diff --git a/packages/apps/src/utils/html-widget.ts b/packages/apps/src/utils/html-widget.ts
new file mode 100644
index 000000000..09407ecc5
--- /dev/null
+++ b/packages/apps/src/utils/html-widget.ts
@@ -0,0 +1,61 @@
+import { IHtmlWidgetPayload } from '@microsoft/teams.api';
+
+/**
+ * Options for building an HTML widget markdown string.
+ */
+export interface IHtmlWidgetMarkdownOptions {
+ /**
+ * Text to include before the widget code block.
+ */
+ before?: string;
+
+ /**
+ * Text to include after the widget code block.
+ */
+ after?: string;
+}
+
+/**
+ * Wraps an HTML widget payload in the ```html-widget markdown code fence format.
+ *
+ * @param payload - The widget payload to serialize.
+ * @param options - Optional text to include before/after the widget block.
+ * @returns The markdown string containing the widget code block.
+ */
+export function buildHtmlWidgetMarkdown(
+ payload: IHtmlWidgetPayload,
+ options?: IHtmlWidgetMarkdownOptions
+): string {
+ const json = JSON.stringify(payload);
+ const parts: string[] = [];
+
+ if (options?.before) {
+ parts.push(options.before, '');
+ }
+
+ parts.push('```html-widget', json, '```');
+
+ if (options?.after) {
+ parts.push('', options.after);
+ }
+
+ return parts.join('\n');
+}
+
+/**
+ * Builds a message activity containing an HTML widget, ready to be sent.
+ *
+ * @param payload - The widget payload to include in the message.
+ * @param options - Optional text to include before/after the widget block.
+ * @returns An activity object with textFormat set to 'extendedmarkdown'.
+ */
+export function buildHtmlWidgetMessage(
+ payload: IHtmlWidgetPayload,
+ options?: IHtmlWidgetMarkdownOptions
+): { type: 'message'; text: string; textFormat: 'extendedmarkdown' } {
+ return {
+ type: 'message',
+ text: buildHtmlWidgetMarkdown(payload, options),
+ textFormat: 'extendedmarkdown',
+ };
+}
From fe7d67a5f4309df8e5201e2549a4bbd5170f8a68 Mon Sep 17 00:00:00 2001
From: Corina Gum <14900841+corinagum@users.noreply.github.com>
Date: Thu, 25 Jun 2026 09:59:45 -0700
Subject: [PATCH 03/12] Fix IHTMLWidgetCallToolResponse wrapper type and update
domain docs
---
.../src/models/html-widget/call-tool-result.ts | 17 +++++++++++++++++
.../models/html-widget/html-widget-payload.ts | 5 ++++-
packages/api/src/models/invoke-response.ts | 4 ++--
3 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/packages/api/src/models/html-widget/call-tool-result.ts b/packages/api/src/models/html-widget/call-tool-result.ts
index c87a802fa..398fecee7 100644
--- a/packages/api/src/models/html-widget/call-tool-result.ts
+++ b/packages/api/src/models/html-widget/call-tool-result.ts
@@ -33,3 +33,20 @@ export interface IMcpUiCallToolResult {
*/
isError?: boolean;
}
+
+/**
+ * The wire-format response body for an `htmlwidget/calltool` invoke.
+ * Teams expects this shape (with `responseType` discriminator) rather than
+ * a bare {@link IMcpUiCallToolResult}.
+ */
+export interface IHtmlWidgetCallToolResponse {
+ /**
+ * Discriminator that tells Teams how to interpret the response.
+ */
+ responseType: 'htmlwidget/calltoolresult';
+
+ /**
+ * The tool call result payload.
+ */
+ callToolResult: IMcpUiCallToolResult;
+}
diff --git a/packages/api/src/models/html-widget/html-widget-payload.ts b/packages/api/src/models/html-widget/html-widget-payload.ts
index 956281b8c..4c74e9579 100644
--- a/packages/api/src/models/html-widget/html-widget-payload.ts
+++ b/packages/api/src/models/html-widget/html-widget-payload.ts
@@ -75,7 +75,10 @@ export interface IHtmlWidgetPayload {
html: string;
/**
- * The domain associated with the widget.
+ * The domain associated with the widget, applied to sandbox metadata.
+ * Must be a valid domain URL (e.g. 'https://example.com'). The domain
+ * does not need to resolve or serve content, but must be non-empty.
+ * This value is available to the rendering MCP App as informational context.
*/
domain: string;
diff --git a/packages/api/src/models/invoke-response.ts b/packages/api/src/models/invoke-response.ts
index a9cd28178..8c992465a 100644
--- a/packages/api/src/models/invoke-response.ts
+++ b/packages/api/src/models/invoke-response.ts
@@ -1,7 +1,7 @@
import {
AdaptiveCardActionResponse,
ConfigResponse,
- IMcpUiCallToolResult,
+ IHtmlWidgetCallToolResponse,
MessagingExtensionActionResponse,
MessagingExtensionResponse,
TabResponse,
@@ -64,5 +64,5 @@ type InvokeResponseBody = {
'signin/verifyState': void;
'signin/failure': void;
'adaptiveCard/action': AdaptiveCardActionResponse;
- 'htmlwidget/calltool': IMcpUiCallToolResult;
+ 'htmlwidget/calltool': IHtmlWidgetCallToolResponse;
};
From e7eda66229891f5e2b18518094598f316ea6d5d3 Mon Sep 17 00:00:00 2001
From: Corina Gum <14900841+corinagum@users.noreply.github.com>
Date: Thu, 25 Jun 2026 10:44:03 -0700
Subject: [PATCH 04/12] Add injectWidgetProtocol helper and payload validation
---
packages/apps/src/index.ts | 4 +-
packages/apps/src/utils/html-widget.spec.ts | 362 ++++++++++++++++++--
packages/apps/src/utils/html-widget.ts | 206 ++++++++++-
3 files changed, 537 insertions(+), 35 deletions(-)
diff --git a/packages/apps/src/index.ts b/packages/apps/src/index.ts
index 9debe384b..61e441a18 100644
--- a/packages/apps/src/index.ts
+++ b/packages/apps/src/index.ts
@@ -12,5 +12,5 @@ export * from './http';
export { toThreadedConversationId } from './utils/thread';
// HTML Widget utilities
-export { buildHtmlWidgetMarkdown, buildHtmlWidgetMessage } from './utils/html-widget';
-export type { IHtmlWidgetMarkdownOptions } from './utils/html-widget';
+export { buildHtmlWidgetMarkdown, buildHtmlWidgetMessage, injectWidgetProtocol } from './utils/html-widget';
+export type { IHtmlWidgetMarkdownOptions, IInjectWidgetProtocolOptions } from './utils/html-widget';
diff --git a/packages/apps/src/utils/html-widget.spec.ts b/packages/apps/src/utils/html-widget.spec.ts
index cbaebba37..9bbd61906 100644
--- a/packages/apps/src/utils/html-widget.spec.ts
+++ b/packages/apps/src/utils/html-widget.spec.ts
@@ -1,6 +1,7 @@
-import { buildHtmlWidgetMarkdown, buildHtmlWidgetMessage } from './html-widget';
import { IHtmlWidgetPayload } from '@microsoft/teams.api';
+import { buildHtmlWidgetMarkdown, buildHtmlWidgetMessage, injectWidgetProtocol } from './html-widget';
+
const MINIMAL_PAYLOAD: IHtmlWidgetPayload = {
type: 'widget/mcp-ui',
name: 'Test Widget',
@@ -16,7 +17,7 @@ const FULL_PAYLOAD: IHtmlWidgetPayload = {
domain: 'https://weather.example.com',
securityPolicy: {
connectDomains: ['https://api.example.com'],
- resourceDomains: ["'self'", 'data:'],
+ resourceDomains: ['\'self\'', 'data:'],
frameDomains: [],
baseUriDomains: [],
},
@@ -28,23 +29,42 @@ const FULL_PAYLOAD: IHtmlWidgetPayload = {
describe('buildHtmlWidgetMarkdown', () => {
it('should wrap payload in html-widget code fence', () => {
const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD);
- expect(result).toBe(
- '```html-widget\n' + JSON.stringify(MINIMAL_PAYLOAD) + '\n```'
- );
+ expect(result.startsWith('```html-widget\n')).toBe(true);
+ expect(result.endsWith('\n```')).toBe(true);
+ });
+
+ it('should auto-inject the widget protocol into the HTML', () => {
+ const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD);
+ const jsonLine = result.split('\n').slice(1, -1).join('\n');
+ const parsed = JSON.parse(jsonLine);
+ expect(parsed.html).toContain('ui/initialize');
+ expect(parsed.html).toContain('Hello
');
+ });
+
+ it('should not double-inject if HTML already has the protocol', () => {
+ const htmlWithInit = 'Hello
';
+ const payload = { ...MINIMAL_PAYLOAD, html: htmlWithInit };
+ const result = buildHtmlWidgetMarkdown(payload);
+ const jsonLine = result.split('\n').slice(1, -1).join('\n');
+ const parsed = JSON.parse(jsonLine);
+ expect(parsed.html).toBe(htmlWithInit);
+ });
+
+ it('should use the payload name as the protocol app name', () => {
+ const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD);
+ const jsonLine = result.split('\n').slice(1, -1).join('\n');
+ const parsed = JSON.parse(jsonLine);
+ expect(parsed.html).toContain('name:\'Test Widget\'');
});
it('should include text before the widget', () => {
const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, { before: 'Check this out:' });
- expect(result).toBe(
- 'Check this out:\n\n```html-widget\n' + JSON.stringify(MINIMAL_PAYLOAD) + '\n```'
- );
+ expect(result.startsWith('Check this out:\n\n```html-widget\n')).toBe(true);
});
it('should include text after the widget', () => {
const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, { after: 'Pretty cool, right?' });
- expect(result).toBe(
- '```html-widget\n' + JSON.stringify(MINIMAL_PAYLOAD) + '\n```\n\nPretty cool, right?'
- );
+ expect(result.endsWith('\n```\n\nPretty cool, right?')).toBe(true);
});
it('should include text before and after the widget', () => {
@@ -52,9 +72,8 @@ describe('buildHtmlWidgetMarkdown', () => {
before: 'Before',
after: 'After',
});
- expect(result).toBe(
- 'Before\n\n```html-widget\n' + JSON.stringify(MINIMAL_PAYLOAD) + '\n```\n\nAfter'
- );
+ expect(result.startsWith('Before\n\n```html-widget\n')).toBe(true);
+ expect(result.endsWith('\n```\n\nAfter')).toBe(true);
});
it('should serialize a full payload with all fields', () => {
@@ -63,17 +82,28 @@ describe('buildHtmlWidgetMarkdown', () => {
expect(parsed.type).toBe('widget/mcp-ui');
expect(parsed.name).toBe('Weather Widget');
expect(parsed.description).toBe('Current weather conditions');
- expect(parsed.html).toBe('72F
');
+ expect(parsed.html).toContain('72F
');
+ expect(parsed.html).toContain('ui/initialize');
expect(parsed.domain).toBe('https://weather.example.com');
expect(parsed.securityPolicy.connectDomains).toEqual(['https://api.example.com']);
expect(parsed.toolInput).toEqual({ location: 'Seattle, WA' });
expect(parsed.permissions).toEqual({ clipboardWrite: {} });
});
- it('should produce valid markdown with no options', () => {
- const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD);
- expect(result.startsWith('```html-widget\n')).toBe(true);
- expect(result.endsWith('\n```')).toBe(true);
+ it('should not overwrite a user-provided securityPolicy with defaults', () => {
+ const customPolicy = {
+ connectDomains: ['https://api.custom.com'],
+ resourceDomains: ['https://cdn.custom.com'],
+ frameDomains: ['https://embed.custom.com'],
+ baseUriDomains: [],
+ };
+ const payload: IHtmlWidgetPayload = {
+ ...MINIMAL_PAYLOAD,
+ securityPolicy: customPolicy,
+ };
+ const result = buildHtmlWidgetMarkdown(payload);
+ const parsed = JSON.parse(result.split('\n')[1]);
+ expect(parsed.securityPolicy).toEqual(customPolicy);
});
it('should handle HTML containing backticks without breaking the fence', () => {
@@ -82,14 +112,11 @@ describe('buildHtmlWidgetMarkdown', () => {
html: '```some code```',
};
const result = buildHtmlWidgetMarkdown(payload);
- // JSON.stringify escapes nothing about backticks, but they appear inside
- // a JSON string value (quoted), so the code fence boundary is unambiguous
- // because the fence opener/closer are on their own lines.
expect(result.startsWith('```html-widget\n')).toBe(true);
expect(result.endsWith('\n```')).toBe(true);
const jsonLine = result.split('\n').slice(1, -1).join('\n');
const parsed = JSON.parse(jsonLine);
- expect(parsed.html).toBe('```some code```');
+ expect(parsed.html).toContain('```some code```');
});
it('should handle HTML with newlines and special characters', () => {
@@ -98,32 +125,37 @@ describe('buildHtmlWidgetMarkdown', () => {
html: '',
};
const result = buildHtmlWidgetMarkdown(payload);
- // JSON.stringify will escape the newlines and quotes inside the string
const jsonLine = result.split('\n')[1];
const parsed = JSON.parse(jsonLine);
- expect(parsed.html).toBe('');
+ expect(parsed.html).toContain('');
});
it('should handle empty string options without adding extra lines', () => {
const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, { before: '', after: '' });
- expect(result).toBe(
- '```html-widget\n' + JSON.stringify(MINIMAL_PAYLOAD) + '\n```'
- );
+ expect(result.startsWith('```html-widget\n')).toBe(true);
+ expect(result.endsWith('\n```')).toBe(true);
+ // No extra blank lines from empty before/after
+ expect(result).not.toMatch(/^\n/);
});
it('should handle payload with undefined optional fields', () => {
const payload: IHtmlWidgetPayload = {
type: 'widget/mcp-ui',
name: 'Bare',
- html: '',
- domain: '',
+ html: 'minimal
',
+ domain: 'https://example.com',
};
const result = buildHtmlWidgetMarkdown(payload);
const jsonLine = result.split('\n')[1];
const parsed = JSON.parse(jsonLine);
expect(parsed.type).toBe('widget/mcp-ui');
expect(parsed.description).toBeUndefined();
- expect(parsed.securityPolicy).toBeUndefined();
+ expect(parsed.securityPolicy).toEqual({
+ connectDomains: [],
+ resourceDomains: ['\'self\'', 'data:'],
+ frameDomains: [],
+ baseUriDomains: [],
+ });
expect(parsed.toolInput).toBeUndefined();
expect(parsed.permissions).toBeUndefined();
});
@@ -154,3 +186,271 @@ describe('buildHtmlWidgetMessage', () => {
expect(result).toHaveProperty('textFormat');
});
});
+
+describe('injectWidgetProtocol', () => {
+ const BARE_HTML = 'Hello
';
+ const BARE_HTML_NO_BODY = 'Hello
';
+
+ it('should inject the protocol script before
test