diff --git a/examples/html-widgets/README.md b/examples/html-widgets/README.md new file mode 100644 index 000000000..5b42ad77a --- /dev/null +++ b/examples/html-widgets/README.md @@ -0,0 +1,62 @@ +# HTML Widgets Example + +This example bot demonstrates the HTML widget contract for Teams bots using the Teams SDK. + +## What it tests + +| Command | Purpose | Widget callbacks | +|---------|---------|-----------------| +| `/simple` | Static widget rendering | None | +| `/calltool` | Widget calling bot tools | `htmlwidget/calltool` invoke | +| `/messageback` | Widget sending messageBack | `onMessage` | +| `/fullscreen` | Widget requesting display mode change | `onRequestDisplayMode` (client-side) | +| `/multi` | Multiple tool dispatch | `htmlwidget/calltool` with different tool names | +| `/raw` | Direct markdown helper usage | None | +| `/permissions` | Widget requesting permissions | None (host-enforced) | + +## Architecture + +``` +Bot sends message: + textFormat: 'extendedmarkdown' + text: "...\n```html-widget\n{JSON payload}\n```" + +Teams client: + McpWidgetRenderer loads widget HTML in sandboxed iframe + @modelcontextprotocol/ext-apps SDK provides callServerTool API + +Widget calls tool: + ext-apps SDK -> postMessage -> McpWidgetRenderer -> htmlwidget/calltool invoke -> Bot + +Bot returns: + { status: 200, body: { content: [...], structuredContent: {...}, isError: false } } +``` + +## Running + +1. Copy credentials: + ``` + cp ../../../bots/.env .env + ``` + +2. Start a devtunnel and update the Azure Bot endpoint + +3. Run the bot: + ``` + npm run dev + ``` + +4. In Teams, message the bot with `/help` to see available commands + +## Note on widget HTML + +The widget HTML in `src/widgets/` is static HTML for rendering verification. +Interactive behavior (callTool, messageBack, displayMode) requires the +`@modelcontextprotocol/ext-apps` SDK which is provided by the Teams widget host +page (`mcpwidget.html` on `widget-renderer.usercontent.microsoft`). + +The bot-side code (`widget.callTool` handler in `src/index.ts`) is the primary +test target. It verifies that: +- The SDK's invoke route alias correctly matches `htmlwidget/calltool` +- The typed handler receives `{ name, arguments }` in `activity.value` +- The response body format (`McpUiCallToolResult`) is accepted by the client diff --git a/examples/html-widgets/eslint.config.js b/examples/html-widgets/eslint.config.js new file mode 100644 index 000000000..5ccf8112f --- /dev/null +++ b/examples/html-widgets/eslint.config.js @@ -0,0 +1 @@ +module.exports = require('@microsoft/teams.config/eslint.config').default; diff --git a/examples/html-widgets/package.json b/examples/html-widgets/package.json new file mode 100644 index 000000000..30685e433 --- /dev/null +++ b/examples/html-widgets/package.json @@ -0,0 +1,31 @@ +{ + "name": "@examples/html-widgets", + "version": "0.0.1", + "private": true, + "license": "MIT", + "main": "dist/index", + "types": "dist/index", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "clean": "npx rimraf ./dist", + "lint": "npx eslint", + "lint:fix": "npx eslint --fix", + "build": "npx tsc", + "start": "node .", + "dev": "tsx watch -r dotenv/config src/index.ts" + }, + "dependencies": { + "@microsoft/teams.apps": "*" + }, + "devDependencies": { + "@microsoft/teams.config": "*", + "@types/node": "^22.5.4", + "dotenv": "^16.4.5", + "rimraf": "^6.0.1", + "tsx": "^4.20.6", + "typescript": "^5.4.5" + } +} diff --git a/examples/html-widgets/src/index.ts b/examples/html-widgets/src/index.ts new file mode 100644 index 000000000..08e9edae9 --- /dev/null +++ b/examples/html-widgets/src/index.ts @@ -0,0 +1,358 @@ +/** + * HTML Widgets Example Bot + * + * This example demonstrates the full HTML widget capabilities for Teams bots. + * Each command shows a different widget feature that developers can use as + * a reference for building their own widget-enabled bots. + */ + +import { IMcpUiCallToolResult } from '@microsoft/teams.api'; +import { App, buildHtmlWidgetMarkdown, buildHtmlWidgetMessage, validateSecurityPolicy } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +import { CALLTOOL_WIDGET_HTML } from './widgets/calltool'; +import { FULLSCREEN_WIDGET_HTML } from './widgets/fullscreen'; +import { HOST_CONTEXT_WIDGET_HTML } from './widgets/host-context'; +import { MESSAGEBACK_WIDGET_HTML } from './widgets/messageback'; +import { MULTI_WIDGET_HTML } from './widgets/multi-tool'; +import { OPEN_LINK_WIDGET_HTML } from './widgets/open-link'; + +import { SIMPLE_WIDGET_HTML } from './widgets/simple'; +import { UPDATE_CONTEXT_WIDGET_HTML } from './widgets/update-context'; + +const app = new App({ + logger: new ConsoleLogger('@examples/html-widgets', { level: 'debug' }), +}); + +// --------------------------------------------------------------------------- +// Simple static widget - no callbacks +// Shows the minimal code to send an HTML widget. +// --------------------------------------------------------------------------- +app.on('message', async ({ send, activity }) => { + if (!activity.text) return; + const text = activity.text.trim().toLowerCase(); + + if (text === '/simple') { + const message = buildHtmlWidgetMessage( + { + type: 'widget/mcp-ui', + name: 'Simple Widget', + description: 'A static HTML widget with no callbacks.', + html: SIMPLE_WIDGET_HTML, + domain: 'https://teams.microsoft.com', + securityPolicy: { + connectDomains: [], + resourceDomains: ['\'self\'', 'data:'], + frameDomains: [], + baseUriDomains: [], + }, + permissions: {}, + }, + { before: 'Here is a simple static widget:', after: 'No callbacks needed for static content.' } + ); + await send(message); + + // Alternative: use buildHtmlWidgetMarkdown for more control over the activity + // const markdown = buildHtmlWidgetMarkdown(payload, { before: '...' }); + // await send({ type: 'message', text: markdown, textFormat: 'extendedmarkdown' }); + return; + } + + // Widget with onCallTool callback + // The widget calls tools on the bot and re-renders with the result. + if (text === '/calltool') { + const message = buildHtmlWidgetMessage( + { + type: 'widget/mcp-ui', + name: 'CallTool Widget', + description: 'Widget that calls tools on the bot.', + html: CALLTOOL_WIDGET_HTML, + domain: 'https://teams.microsoft.com', + securityPolicy: { + connectDomains: ['https://teams.microsoft.com', 'https://teams.cloud.microsoft.com'], + resourceDomains: ['\'self\'', 'data:'], + frameDomains: [], + baseUriDomains: [], + }, + toolInput: { demo: true }, + toolOutput: { + content: [{ type: 'text', text: 'Initial data loaded.' }], + structuredContent: { counter: 0, lastAction: 'init' }, + isError: false, + }, + permissions: {}, + }, + { before: 'Here is a widget with callTool support (click Refresh):' } + ); + await send(message); + return; + } + + // Widget with onMessage (messageBack) callback + // Tests that the widget can send messageBack to the bot. + if (text === '/messageback') { + const message = buildHtmlWidgetMessage( + { + type: 'widget/mcp-ui', + name: 'MessageBack Widget', + description: 'Widget that sends messageBack to the bot.', + html: MESSAGEBACK_WIDGET_HTML, + domain: 'https://teams.microsoft.com', + securityPolicy: { + connectDomains: [], + resourceDomains: ['\'self\'', 'data:'], + frameDomains: [], + baseUriDomains: [], + }, + permissions: {}, + }, + { before: 'This widget tests the onMessage (messageBack) callback:' } + ); + await send(message); + return; + } + + // Widget requesting fullscreen display mode + // Tests onRequestDisplayMode with "fullscreen" value. + if (text === '/fullscreen') { + const message = buildHtmlWidgetMessage( + { + type: 'widget/mcp-ui', + name: 'Fullscreen Widget', + description: 'Widget that requests fullscreen mode.', + html: FULLSCREEN_WIDGET_HTML, + domain: 'https://teams.microsoft.com', + securityPolicy: { + connectDomains: [], + resourceDomains: ['\'self\'', 'data:'], + frameDomains: [], + baseUriDomains: [], + }, + permissions: {}, + }, + { before: 'This widget will request fullscreen mode:' } + ); + await send(message); + return; + } + + // Widget with multiple tools + // Tests that calltool dispatches correctly by tool name. + if (text === '/multi') { + const message = buildHtmlWidgetMessage( + { + type: 'widget/mcp-ui', + name: 'Multi-Tool Widget', + description: 'Widget that calls multiple different tools.', + html: MULTI_WIDGET_HTML, + domain: 'https://teams.microsoft.com', + securityPolicy: { + connectDomains: ['https://teams.microsoft.com'], + resourceDomains: ['\'self\'', 'data:'], + frameDomains: [], + baseUriDomains: [], + }, + toolInput: {}, + toolOutput: { + content: [{ type: 'text', text: 'Ready.' }], + structuredContent: { tools: ['getTime', 'roll', 'echo'] }, + isError: false, + }, + permissions: {}, + }, + { before: 'This widget has multiple tools to test dispatch:' } + ); + await send(message); + return; + } + + // Widget using ui/open-link + if (text === '/openlink') { + const message = buildHtmlWidgetMessage( + { + type: 'widget/mcp-ui', + name: 'open-link-test', + html: OPEN_LINK_WIDGET_HTML, + domain: 'https://teams.microsoft.com', + }, + { before: 'Widget with ui/open-link support (click a button to open a URL):' } + ); + await send(message); + return; + } + + // Widget using ui/update-model-context + if (text === '/context') { + const message = buildHtmlWidgetMessage( + { + type: 'widget/mcp-ui', + name: 'update-context-test', + html: UPDATE_CONTEXT_WIDGET_HTML, + domain: 'https://teams.microsoft.com', + }, + { before: 'Widget with ui/update-model-context support:' } + ); + await send(message); + return; + } + + // Host context inspector - shows hostContext from ui/initialize + if (text === '/hostcontext') { + const message = buildHtmlWidgetMessage( + { + type: 'widget/mcp-ui', + name: 'host-context-inspector', + html: HOST_CONTEXT_WIDGET_HTML, + domain: 'https://teams.microsoft.com', + }, + { before: 'Widget that inspects hostContext from ui/initialize:' } + ); + await send(message); + return; + } + + // Security policy validation + // Demonstrates validateSecurityPolicy catching mismatched references. + // This is a dev-time audit tool, not a security boundary - the browser's + // CSP enforcement is the real protection. Use debugCspViolations for runtime. + if (text === '/validate') { + const htmlWithExternalRefs = ` + +
+

Validation Demo

+

This widget was validated before sending.

+
`; + + // Step 1: validate against a restrictive policy to catch issues + const strictPolicy = { + connectDomains: [], + resourceDomains: ['\'self\'', 'data:'], + frameDomains: [], + baseUriDomains: [], + }; + const warnings = validateSecurityPolicy(htmlWithExternalRefs, strictPolicy); + + // Step 2: fix the policy based on warnings, then build the widget + const correctedPolicy = { + ...strictPolicy, + resourceDomains: [...strictPolicy.resourceDomains, 'https://fonts.googleapis.com'], + }; + const warningText = warnings + .map((w) => `- **${w.source}**: \`${w.url}\` not in \`${w.policyField}\``) + .join('\n'); + const markdown = buildHtmlWidgetMarkdown( + { + type: 'widget/mcp-ui', + name: 'Validated Widget', + description: 'Widget built after security policy validation.', + html: htmlWithExternalRefs, + domain: 'https://teams.microsoft.com', + securityPolicy: correctedPolicy, + }, + { + before: + `**Validation found ${warnings.length} warning(s):**\n\n` + + warningText + '\n\n' + + 'Policy was corrected before sending:', + } + ); + await send({ type: 'message', text: markdown, textFormat: 'extendedmarkdown' }); + return; + } + + // Default: show help + if (text === '/help' || text === 'help') { + await send({ + type: 'message', + textFormat: 'markdown', + text: + '**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', + }); + return; + } + + // Handle messageBack values from the messageback widget + if (activity.value) { + await send(`Received messageBack value: ${JSON.stringify(activity.value)}`); + return; + } + + await send('Send `/help` for available widget test commands.'); +}); + +// --------------------------------------------------------------------------- +// Handle htmlwidget/calltool invoke +// This is the typed handler for when a widget calls a tool on the bot. +// --------------------------------------------------------------------------- +app.on('widget.callTool', async ({ activity }) => { + const { name, arguments: args } = activity.value; + console.log(`[widget.callTool] tool="${name}" args=${JSON.stringify(args)}`); + + let callToolResult: IMcpUiCallToolResult; + switch (name) { + case 'refresh': + callToolResult = { + content: [{ type: 'text', text: 'Refreshed!' }], + structuredContent: { + counter: ((args as any)?.counter ?? 0) + 1, + lastAction: 'refresh', + timestamp: new Date().toISOString(), + }, + isError: false, + }; + break; + + case 'getTime': + callToolResult = { + content: [{ type: 'text', text: new Date().toLocaleTimeString() }], + structuredContent: { time: new Date().toISOString() }, + isError: false, + }; + break; + + case 'roll': { + const sides = (args as any)?.sides ?? 6; + const result = Math.floor(Math.random() * sides) + 1; + callToolResult = { + content: [{ type: 'text', text: `Rolled a ${result} (d${sides})` }], + structuredContent: { result, sides }, + isError: false, + }; + break; + } + + case 'echo': + callToolResult = { + content: [{ type: 'text', text: JSON.stringify(args) }], + structuredContent: args, + isError: false, + }; + break; + + default: + callToolResult = { + content: [{ type: 'text', text: `Unknown tool: ${name}` }], + isError: true, + }; + break; + } + + console.log('[widget.callTool] result=', JSON.stringify(callToolResult)); + + return { + responseType: 'htmlwidget/calltoolresult', + callToolResult, + }; +}); + +app.start(process.env.PORT || 3978).catch(console.error); diff --git a/examples/html-widgets/src/widgets/calltool.ts b/examples/html-widgets/src/widgets/calltool.ts new file mode 100644 index 000000000..d423f904f --- /dev/null +++ b/examples/html-widgets/src/widgets/calltool.ts @@ -0,0 +1,8 @@ +/** + * CallTool widget - calls a "refresh" tool on the bot and displays the result. + * + * This HTML includes the interactive calltool behavior (tools/call) since + * that is widget-specific logic, not boilerplate. The example bot uses + * injectWidgetProtocol() automatically via the builders. + */ +export const CALLTOOL_WIDGET_HTML = '

CallTool Widget

Click Refresh to call the bot\'s "refresh" tool.

Waiting for action...
'; diff --git a/examples/html-widgets/src/widgets/fullscreen.ts b/examples/html-widgets/src/widgets/fullscreen.ts new file mode 100644 index 000000000..8d6627d87 --- /dev/null +++ b/examples/html-widgets/src/widgets/fullscreen.ts @@ -0,0 +1,7 @@ +/** + * Fullscreen widget - requests fullscreen display mode from the host. + * + * Uses requestDisplayMode to ask the Teams host for fullscreen mode. + * The example bot uses injectWidgetProtocol() automatically via the builders. + */ +export const FULLSCREEN_WIDGET_HTML = '

Fullscreen Widget

Click the button to request fullscreen mode from Teams.

In fullscreen mode, this widget will expand to fill the available space.

Current mode: inline

'; diff --git a/examples/html-widgets/src/widgets/host-context.ts b/examples/html-widgets/src/widgets/host-context.ts new file mode 100644 index 000000000..35bfa9e99 --- /dev/null +++ b/examples/html-widgets/src/widgets/host-context.ts @@ -0,0 +1,92 @@ +/** + * Host Context widget - displays the hostContext received during ui/initialize. + * Shows theme, display mode, container dimensions, locale, etc. + * Also listens for ui/notifications/host-context-changed. + */ +export const HOST_CONTEXT_WIDGET_HTML = ` + + +

Host Context Inspector

+

Displays the hostContext from ui/initialize response and listens for changes.

+
+

Initialize Result

+
Waiting for initialize...
+
+
+

Host Context

+
-
+
+
+

Host Capabilities

+
-
+
+
+ +`; diff --git a/examples/html-widgets/src/widgets/messageback.ts b/examples/html-widgets/src/widgets/messageback.ts new file mode 100644 index 000000000..ac56f6063 --- /dev/null +++ b/examples/html-widgets/src/widgets/messageback.ts @@ -0,0 +1,8 @@ +/** + * MessageBack widget - sends a messageBack action to the bot. + * + * Uses the ui/message method to send a message to the conversation, + * similar to messageBack in Adaptive Cards. The example bot uses + * injectWidgetProtocol() automatically via the builders. + */ +export const MESSAGEBACK_WIDGET_HTML = '

MessageBack Widget

Click the button to send a messageBack to the bot.

'; diff --git a/examples/html-widgets/src/widgets/multi-tool.ts b/examples/html-widgets/src/widgets/multi-tool.ts new file mode 100644 index 000000000..0a954429c --- /dev/null +++ b/examples/html-widgets/src/widgets/multi-tool.ts @@ -0,0 +1,8 @@ +/** + * Multi-tool widget - calls multiple different tools on the bot. + * + * Each button calls tools/call with a different tool name. Teams routes + * each as an `htmlwidget/calltool` invoke activity to the bot. The example + * bot uses injectWidgetProtocol() automatically via the builders. + */ +export const MULTI_WIDGET_HTML = '

Multi-Tool Widget

Each button calls a different tool on the bot.

Available tools: getTime, roll, echo, unknownTool
'; diff --git a/examples/html-widgets/src/widgets/open-link.ts b/examples/html-widgets/src/widgets/open-link.ts new file mode 100644 index 000000000..0057ac89b --- /dev/null +++ b/examples/html-widgets/src/widgets/open-link.ts @@ -0,0 +1,58 @@ +/** + * Open Link widget - tests ui/open-link method. + * Clicking a button asks the host to open a URL in the user's browser. + */ +export const OPEN_LINK_WIDGET_HTML = ` + + +

Open Link Widget

+

Tests the ui/open-link method (host opens a URL).

+
+ + + +
+
Waiting...
+ +`; diff --git a/examples/html-widgets/src/widgets/simple.ts b/examples/html-widgets/src/widgets/simple.ts new file mode 100644 index 000000000..8510d9b66 --- /dev/null +++ b/examples/html-widgets/src/widgets/simple.ts @@ -0,0 +1,8 @@ +/** + * Simple static widget - no callbacks, no interactivity. + * Verifies that the host renders the HTML correctly. + * + * This is raw HTML without the MCP Apps protocol. The example bot uses + * injectWidgetProtocol() automatically via the builders. + */ +export const SIMPLE_WIDGET_HTML = '

Simple HTML Widget

This is a static HTML widget rendered inside a Teams message. No callbacks are needed.

Status: Rendered successfully
'; diff --git a/examples/html-widgets/src/widgets/update-context.ts b/examples/html-widgets/src/widgets/update-context.ts new file mode 100644 index 000000000..331ab0389 --- /dev/null +++ b/examples/html-widgets/src/widgets/update-context.ts @@ -0,0 +1,91 @@ +/** + * Update Model Context widget - tests ui/update-model-context method. + * Sends structured context to the host that can be used by AI in future turns. + */ +export const UPDATE_CONTEXT_WIDGET_HTML = ` + + +

Update Model Context Widget

+

Tests ui/update-model-context - sends context for AI to use in future turns.

+ +
+ + + +
+
Waiting...
+ +`; diff --git a/examples/html-widgets/tsconfig.json b/examples/html-widgets/tsconfig.json new file mode 100644 index 000000000..9a42fe553 --- /dev/null +++ b/examples/html-widgets/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@microsoft/teams.config/tsconfig.node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/package-lock.json b/package-lock.json index 0036bab51..7c167fe2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -215,6 +215,22 @@ "typescript": "^5.4.5" } }, + "examples/html-widgets": { + "name": "@examples/html-widgets", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@microsoft/teams.apps": "*" + }, + "devDependencies": { + "@microsoft/teams.config": "*", + "@types/node": "^22.5.4", + "dotenv": "^16.4.5", + "rimraf": "^6.0.1", + "tsx": "^4.20.6", + "typescript": "^5.4.5" + } + }, "examples/http-adapters": { "name": "@examples/http-adapters", "version": "0.0.1", @@ -2213,6 +2229,10 @@ "resolved": "examples/formatted-messaging", "link": true }, + "node_modules/@examples/html-widgets": { + "resolved": "examples/html-widgets", + "link": true + }, "node_modules/@examples/http-adapters": { "resolved": "examples/http-adapters", "link": true @@ -5912,6 +5932,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5926,6 +5949,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5940,6 +5966,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5954,6 +5983,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5968,6 +6000,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5982,6 +6017,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5996,6 +6034,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6010,6 +6051,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6024,6 +6068,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6038,6 +6085,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6052,6 +6102,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6065,6 +6118,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6079,6 +6135,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -11770,9 +11829,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -16553,6 +16612,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -17661,6 +17723,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ 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..e0c8ce326 --- /dev/null +++ b/packages/api/src/models/html-widget/call-tool-result.ts @@ -0,0 +1,58 @@ +/** + * 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. + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsHtmlWidget + */ +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; +} + +/** + * The wire-format response body for an `htmlwidget/calltool` invoke. + * Teams expects this shape (with `responseType` discriminator) rather than + * a bare {@link IMcpUiCallToolResult}. + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsHtmlWidget + */ +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 new file mode 100644 index 000000000..047316d48 --- /dev/null +++ b/packages/api/src/models/html-widget/html-widget-payload.ts @@ -0,0 +1,113 @@ +/** + * The security policy for an HTML widget, controlling allowed origins + * for network requests, static resources, nested iframes, and base URIs. + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsHtmlWidget + */ +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. + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsHtmlWidget + */ +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. + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsHtmlWidget + */ +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, 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; + + /** + * 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..8c992465a 100644 --- a/packages/api/src/models/invoke-response.ts +++ b/packages/api/src/models/invoke-response.ts @@ -1,6 +1,7 @@ import { AdaptiveCardActionResponse, ConfigResponse, + IHtmlWidgetCallToolResponse, MessagingExtensionActionResponse, MessagingExtensionResponse, TabResponse, @@ -63,4 +64,5 @@ type InvokeResponseBody = { 'signin/verifyState': void; 'signin/failure': void; 'adaptiveCard/action': AdaptiveCardActionResponse; + 'htmlwidget/calltool': IHtmlWidgetCallToolResponse; }; diff --git a/packages/apps/src/index.ts b/packages/apps/src/index.ts index 98918b252..b629ba63b 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, injectWidgetProtocol, validateSecurityPolicy } from './utils/html-widget'; +export type { IHtmlWidgetMarkdownOptions, IInjectWidgetProtocolOptions, ISecurityPolicyWarning } 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..e59411c87 --- /dev/null +++ b/packages/apps/src/routes/invoke/widget-calltool.ts @@ -0,0 +1,15 @@ +import { IHtmlWidgetCallToolInvokeActivity, InvokeResponse } from '@microsoft/teams.api'; + +import { IActivityContext } from '../../contexts'; +import { RouteHandler } from '../../types'; + +/** + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsHtmlWidget + */ +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..1b4f607ba --- /dev/null +++ b/packages/apps/src/utils/html-widget.spec.ts @@ -0,0 +1,723 @@ +import { IHtmlWidgetPayload, IHtmlWidgetSecurityPolicy } from '@microsoft/teams.api'; + +import { buildHtmlWidgetMarkdown, buildHtmlWidgetMessage, injectWidgetProtocol, validateSecurityPolicy } from './html-widget'; + +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.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.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.endsWith('\n```\n\nPretty cool, right?')).toBe(true); + }); + + it('should include text before and after the widget', () => { + const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, { + before: 'Before', + after: 'After', + }); + expect(result.startsWith('Before\n\n```html-widget\n')).toBe(true); + expect(result.endsWith('\n```\n\nAfter')).toBe(true); + }); + + it('should forward protocolOptions to injectWidgetProtocol', () => { + const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, { + protocolOptions: { + notifications: ['tool-result'], + }, + }); + const jsonLine = result.split('\n').slice(1, -1).join('\n'); + const parsed = JSON.parse(jsonLine); + expect(parsed.html).toContain('ui/notifications/tool-result'); + expect(parsed.html).toContain('window.onToolResult'); + }); + + it('should forward debugCspViolations through protocolOptions', () => { + const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, { + protocolOptions: { + debugCspViolations: true, + }, + }); + const jsonLine = result.split('\n').slice(1, -1).join('\n'); + const parsed = JSON.parse(jsonLine); + expect(parsed.html).toContain('securitypolicyviolation'); + }); + + it('should use payload name even when protocolOptions is provided', () => { + const result = buildHtmlWidgetMarkdown(MINIMAL_PAYLOAD, { + protocolOptions: { + version: '2.0.0', + }, + }); + const jsonLine = result.split('\n').slice(1, -1).join('\n'); + const parsed = JSON.parse(jsonLine); + expect(parsed.html).toContain('name:\'Test Widget\''); + expect(parsed.html).toContain('version:\'2.0.0\''); + }); + + 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).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 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', () => { + const payload: IHtmlWidgetPayload = { + ...MINIMAL_PAYLOAD, + html: '```some code```', + }; + const result = buildHtmlWidgetMarkdown(payload); + 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).toContain('```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); + const jsonLine = result.split('\n')[1]; + const parsed = JSON.parse(jsonLine); + 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.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: '

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).toEqual({ + connectDomains: [], + resourceDomains: ['\'self\'', 'data:'], + frameDomains: [], + baseUriDomains: [], + }); + 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'); + }); +}); + +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('