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: '
\n

"Hello" & \'world\'

\n
', + }; + 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('
\n

"Hello" & \'world\'

\n
'); + }); + + 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: '
\n

"Hello" & \'world\'

\n
', }; 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('
\n

"Hello" & \'world\'

\n
'); + expect(parsed.html).toContain('
\n

"Hello" & \'world\'

\n
'); }); 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 ', () => { + const result = injectWidgetProtocol(BARE_HTML); + expect(result).toContain('ui/initialize'); + expect(result).toContain('ui/notifications/size-changed'); + expect(result).toContain('ui/notifications/initialized'); + expect(result).toContain(''); + // Script should come before + const scriptIdx = result.indexOf('ui/initialize'); + const bodyIdx = result.indexOf(''); + expect(scriptIdx).toBeLessThan(bodyIdx); + }); + + it('should append script if no tag exists', () => { + const result = injectWidgetProtocol(BARE_HTML_NO_BODY); + expect(result).toContain('ui/initialize'); + expect(result).toContain('

Hello

'); + }); + + it('should use custom app name and version', () => { + const result = injectWidgetProtocol(BARE_HTML, { name: 'my-widget', version: '2.0.0' }); + expect(result).toContain('name:\'my-widget\''); + expect(result).toContain('version:\'2.0.0\''); + }); + + it('should use default name and version when not provided', () => { + const result = injectWidgetProtocol(BARE_HTML); + expect(result).toContain('name:\'widget\''); + expect(result).toContain('version:\'1.0.0\''); + }); + + it('should not modify HTML that already contains ui/initialize', () => { + const htmlWithInit = ''; + const result = injectWidgetProtocol(htmlWithInit); + expect(result).toBe(htmlWithInit); + }); + + it('should be idempotent -- calling twice produces the same output', () => { + const first = injectWidgetProtocol(BARE_HTML); + const second = injectWidgetProtocol(first); + expect(second).toBe(first); + }); + + it('should handle empty string HTML', () => { + const result = injectWidgetProtocol(''); + expect(result).toContain('ui/initialize'); + expect(result).toContain('