diff --git a/src/vs/base/browser/touch.ts b/src/vs/base/browser/touch.ts index ac526347c908f..8e857e292df7e 100644 --- a/src/vs/base/browser/touch.ts +++ b/src/vs/base/browser/touch.ts @@ -122,6 +122,9 @@ export class Gesture extends Disposable { return toDisposable(remove); } + /** + * Whether the device is able to represent touch events. + */ @memoize static isTouchDevice(): boolean { // `'ontouchstart' in window` always evaluates to true with typescript's modern typings. This causes `window` to be @@ -129,6 +132,14 @@ export class Gesture extends Disposable { return 'ontouchstart' in mainWindow || navigator.maxTouchPoints > 0; } + /** + * Whether the device's primary input is able to hover. + */ + @memoize + static isHoverDevice(): boolean { + return mainWindow.matchMedia('(hover: hover)').matches; + } + public override dispose(): void { if (this.handle) { this.handle.dispose(); diff --git a/src/vs/platform/mcp/common/mcpManagement.ts b/src/vs/platform/mcp/common/mcpManagement.ts index d67ff47fb319c..9c2b7e73d9023 100644 --- a/src/vs/platform/mcp/common/mcpManagement.ts +++ b/src/vs/platform/mcp/common/mcpManagement.ts @@ -249,6 +249,7 @@ export const mcpAccessConfig = 'chat.mcp.access'; export const mcpGalleryServiceUrlConfig = 'chat.mcp.gallery.serviceUrl'; export const mcpGalleryServiceEnablementConfig = 'chat.mcp.gallery.enabled'; export const mcpAutoStartConfig = 'chat.mcp.autostart'; +export const mcpAppsEnabledConfig = 'chat.mcp.apps.enabled'; export interface IMcpGalleryConfig { readonly serviceUrl?: string; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index fac472cb5be90..83a3e633f33d0 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -19,7 +19,7 @@ import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurati import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { McpAccessValue, McpAutoStartValue, mcpAccessConfig, mcpAutoStartConfig, mcpGalleryServiceEnablementConfig, mcpGalleryServiceUrlConfig } from '../../../../platform/mcp/common/mcpManagement.js'; +import { McpAccessValue, McpAutoStartValue, mcpAccessConfig, mcpAutoStartConfig, mcpGalleryServiceEnablementConfig, mcpGalleryServiceUrlConfig, mcpAppsEnabledConfig } from '../../../../platform/mcp/common/mcpManagement.js'; import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; @@ -465,6 +465,12 @@ configurationRegistry.registerConfiguration({ ], tags: ['experimental'], }, + [mcpAppsEnabledConfig]: { + type: 'boolean', + description: nls.localize('chat.mcp.ui.enabled', "Controls whether MCP servers can provide custom UI for tool invocations."), + default: false, + tags: ['experimental'], + }, [mcpServerSamplingSection]: { type: 'object', description: nls.localize('chat.mcp.serverSampling', "Configures which models are exposed to MCP servers for sampling (making model requests in the background). This setting can be edited in a graphical way under the `{0}` command.", 'MCP: ' + nls.localize('mcp.list', 'List Servers')), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts index 38bcd3048cf17..3d06c19ebd2b4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts @@ -29,6 +29,12 @@ export interface IChatContentPart extends IDisposable { */ hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean; + /** + * Called when the content part is mounted to the DOM after being detached + * due to virtualization. + */ + onDidRemount?(): void; + addDisposable?(disposable: IDisposable): void; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts index 7393e03923692..63c18998faa3a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts @@ -96,7 +96,7 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS this.getAutoApproveMessageContent(), context, toCodePart(input), - processedOutput && { + processedOutput && processedOutput.length > 0 ? { parts: processedOutput.map((o, i): ChatCollapsibleIOPart => { const permalinkBasename = o.type === 'ref' || o.uri ? basename(o.uri!) @@ -124,7 +124,7 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS return { kind: 'data', value: decoded || new TextEncoder().encode(o.value), mimeType: o.mimeType, uri: permalinkUri, audience: o.audience }; } }), - }, + } : undefined, isError, ChatInputOutputMarkdownProgressPart._expandedByDefault.get(toolInvocation) ?? false, )); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts new file mode 100644 index 0000000000000..458f8f484a264 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts @@ -0,0 +1,556 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../../base/browser/dom.js'; +import { softAssertNever } from '../../../../../../../base/common/assert.js'; +import { disposableTimeout } from '../../../../../../../base/common/async.js'; +import { decodeBase64 } from '../../../../../../../base/common/buffer.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { autorun, autorunSelfDisposable, derived, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; +import { basename } from '../../../../../../../base/common/resources.js'; +import { isFalsyOrWhitespace } from '../../../../../../../base/common/strings.js'; +import { hasKey, isDefined } from '../../../../../../../base/common/types.js'; +import { localize } from '../../../../../../../nls.js'; +import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../../../platform/log/common/log.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../../../../platform/product/common/productService.js'; +import { IStorageService } from '../../../../../../../platform/storage/common/storage.js'; +import { IMcpAppResourceContent, McpToolCallUI } from '../../../../../mcp/browser/mcpToolCallUI.js'; +import { McpResourceURI } from '../../../../../mcp/common/mcpTypes.js'; +import { MCP } from '../../../../../mcp/common/modelContextProtocol.js'; +import { McpApps } from '../../../../../mcp/common/modelContextProtocolApps.js'; +import { IWebviewElement, IWebviewService, WebviewContentPurpose, WebviewOriginStore } from '../../../../../webview/browser/webview.js'; +import { IChatRequestVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; +import { isToolResultInputOutputDetails, IToolResult } from '../../../../common/tools/languageModelToolsService.js'; +import { IChatWidgetService } from '../../../chat.js'; +import { IMcpAppRenderData } from './chatMcpAppSubPart.js'; + +/** Storage key for persistent webview origins */ +const ORIGIN_STORE_KEY = 'chatMcpApp.origins'; + +/** + * Load state for the MCP App model. + */ +export type McpAppLoadState = + | { readonly status: 'loading' } + | { readonly status: 'loaded' } + | { readonly status: 'error'; readonly error: Error }; + +/** + * Model that owns an MCP App webview and all its state/logic. + * The webview is created lazily on first claim and survives across re-renders. + */ +export class ChatMcpAppModel extends Disposable { + public static maxWebviewHeightPct = 0.8; + + /** Origin store for persistent webview origins per server */ + private readonly _originStore: WebviewOriginStore; + + /** The webview element instance */ + private readonly _webview: IWebviewElement; + + /** Tool call UI for loading resources and proxying calls */ + private readonly _mcpToolCallUI: McpToolCallUI; + + /** Cancellation source for async operations */ + private readonly _disposeCts = this._register(new CancellationTokenSource()); + + /** Whether ui/initialize has been called and capabilities announced */ + private _announcedCapabilities = false; + + /** Current height of the webview */ + private _height: number = 300; + + /** The persistent webview origin */ + private readonly _webviewOrigin: string; + + /** Observable for load state */ + private readonly _loadState = observableValue(this, { status: 'loading' }); + public readonly loadState: IObservable = this._loadState; + + /** Event fired when height changes */ + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; + + /** Host context observable combining tool call UI context with viewport */ + private readonly _viewportObs = observableValue>(this, undefined); + + /** Full host context for the MCP App */ + public readonly hostContext: IObservable; + + constructor( + public readonly toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, + public readonly renderData: IMcpAppRenderData, + private readonly _container: HTMLElement, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IWebviewService private readonly _webviewService: IWebviewService, + @IStorageService storageService: IStorageService, + @ILogService private readonly _logService: ILogService, + @IProductService private readonly _productService: IProductService, + @IOpenerService private readonly _openerService: IOpenerService, + ) { + super(); + + this._originStore = new WebviewOriginStore(ORIGIN_STORE_KEY, storageService); + this._webviewOrigin = this._originStore.getOrigin('mcpApp', renderData.serverDefinitionId); + this._mcpToolCallUI = this._register(this._instantiationService.createInstance(McpToolCallUI, renderData)); + + // Create the webview element + this._webview = this._register(this._webviewService.createWebviewElement({ + origin: this._webviewOrigin, + title: localize('mcpAppTitle', 'MCP App'), + options: { + purpose: WebviewContentPurpose.ChatOutputItem, + enableFindWidget: false, + disableServiceWorker: true, + retainContextWhenHidden: true, + }, + contentOptions: { + allowMultipleAPIAcquire: true, + allowScripts: true, + allowForms: true, + }, + extension: undefined, + })); + + // Mount the webview to the container + const targetWindow = dom.getWindow(this._container); + this._webview.mountTo(this._container, targetWindow); + + // Set up resize observer for viewport and size notifications + const updateViewport = () => { + this._viewportObs.set({ + width: targetWindow.innerWidth, + height: targetWindow.innerHeight, + maxWidth: targetWindow.innerWidth, + maxHeight: targetWindow.innerHeight * ChatMcpAppModel.maxWebviewHeightPct, + }, undefined); + + if (this._announcedCapabilities) { + this._sendNotification({ + method: 'ui/notifications/size-changed', + params: { width: this._container.clientWidth, height: this._container.clientHeight }, + }); + } + }; + + const resizeObserver = new ResizeObserver(updateViewport); + resizeObserver.observe(this._container); + this._register(toDisposable(() => resizeObserver.disconnect())); + updateViewport(); + + // Build host context observable + this.hostContext = this._mcpToolCallUI.hostContext.map((context, reader) => ({ + ...context, + viewport: this._viewportObs.read(reader), + toolCall: { + toolCallId: this.toolInvocation.toolCallId, + toolName: this.toolInvocation.toolId, + }, + })); + + // Set up host context change notifications + this._register(autorun(reader => { + const context = this.hostContext.read(reader); + if (this._announcedCapabilities) { + this._sendNotification({ + method: 'ui/notifications/host-context-changed', + params: context + }); + } + })); + + // Set up message handling + this._register(this._webview.onMessage(async ({ message }) => { + await this._handleWebviewMessage(message as McpApps.AppMessage); + })); + + const canScrollWithin = derived(reader => { + const contentSize = this._webview.intrinsicContentSize.read(reader); + const viewportSize = this._viewportObs.read(reader); + if (!contentSize || !viewportSize) { + return false; + } + + return contentSize.height > viewportSize.maxHeight; + }); + + // Handle wheel events for scroll delegation when the webview can scroll + this._register(autorun(reader => { + if (!canScrollWithin.read(reader)) { + const widget = this._chatWidgetService.getWidgetBySessionResource(this.renderData.sessionResource); + reader.store.add(this._webview.onDidWheel(e => { + widget?.delegateScrollFromMouseWheelEvent({ + ...e, + preventDefault: () => { }, + stopPropagation: () => { } + }); + })); + } + })); + + // Start loading the content + this._loadContent(); + } + + /** + * Gets the current height of the webview. + */ + public get height(): number { + return this._height; + } + + public remount() { + this._webview.reinitializeAfterDismount(); + this._announcedCapabilities = false; + } + + /** + * Retries loading the MCP App content. + */ + public retry(): void { + this._loadState.set({ status: 'loading' }, undefined); + this._loadContent(); + } + + /** + * Loads the MCP App content into the webview. + */ + private async _loadContent(): Promise { + const token = this._disposeCts.token; + + try { + // Load the UI resource from the MCP server + const resourceContent = await this._mcpToolCallUI.loadResource(token); + if (token.isCancellationRequested) { + return; + } + + // Inject CSP into the HTML + const htmlWithCsp = this._injectPreamble(resourceContent); + + // Reset the state + this._announcedCapabilities = false; + + // Set the HTML content + this._webview.setHtml(htmlWithCsp); + + this._loadState.set({ status: 'loaded' }, undefined); + } catch (error) { + this._logService.error('[MCP App] Error loading app:', error); + this._loadState.set({ status: 'error', error: error as Error }, undefined); + } + } + + /** + * Injects a Content-Security-Policy meta tag into the HTML. + */ + private _injectPreamble({ html, csp }: IMcpAppResourceContent): string { + // Note: this is not bulletproof against malformed domains. However it does not + // need to be. The server is the one giving us both the CSP as well as the HTML + // to render in the iframe. MCP Apps give the CSP separately so that systems that + // proxy the HTML from a server can set it in a header, but the CSP and the HTML + // come from the same source and are within the same trust boundary. We only + // process the CSP enough (escaping HTML special characters) to avoid breaking it. + // + // It would certainly be more durable to use `DOMParser.parseFromString` here + // and operate on the DocumentFragment of the HTML, however (even though keeping + // it solely as a detached document is safe) this requires making the HTML trusted + // in the renderer and bypassing various tsec warnings. I consider the string + // munging here to be the lesser of two evils. + const cleanDomains = (s: string[] | undefined) => (s?.join(' ') || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + + const cspContent = ` + default-src 'none'; + script-src 'self' 'unsafe-inline'; + style-src 'self' 'unsafe-inline'; + connect-src 'self' ${cleanDomains(csp?.connectDomains)}; + img-src 'self' data: ${cleanDomains(csp?.resourceDomains)}; + font-src 'self' ${cleanDomains(csp?.resourceDomains)}; + media-src 'self' data: ${cleanDomains(csp?.resourceDomains)}; + frame-src 'none'; + object-src 'none'; + base-uri 'self'; + `; + + const cspTag = ``; + + // window.top and window.parent get reset to `window` after the vscode API is made. + // However, the MCP App SDK by default tries to use these for postMessage. So, wrap them. + // https://github.com/microsoft/vscode/blob/2a4c8f5b8a715d45dd2a36778906b5810e4a1905/src/vs/workbench/contrib/webview/browser/pre/index.html#L242-L244 + const postMessageRehoist = ` + + `; + + return this._prependToHead(html, cspTag + postMessageRehoist); + } + + private _prependToHead(html: string, content: string): string { + // Try to inject into + const headMatch = html.match(/]*>/i); + if (headMatch) { + const insertIndex = headMatch.index! + headMatch[0].length; + return html.slice(0, insertIndex) + '\n' + content + html.slice(insertIndex); + } + + // If no , try to inject after + const htmlMatch = html.match(/]*>/i); + if (htmlMatch) { + const insertIndex = htmlMatch.index! + htmlMatch[0].length; + return html.slice(0, insertIndex) + '\n' + content + '' + html.slice(insertIndex); + } + + // If no , prepend + return `${content}${html}`; + } + + /** + * Handles incoming JSON-RPC messages from the webview. + */ + private async _handleWebviewMessage(message: McpApps.AppMessage): Promise { + const request = message; + const token = this._disposeCts.token; + + try { + let result: McpApps.HostResult = {}; + + switch (request.method) { + case 'ui/initialize': + result = await this._handleInitialize(request.params); + break; + + case 'tools/call': + result = await this._handleToolsCall(request.params, token); + break; + + case 'resources/read': + result = await this._handleResourcesRead(request.params, token); + break; + + case 'ping': + break; + + case 'ui/notifications/size-changed': + this._handleSizeChanged(request.params); + break; + + case 'ui/open-link': + result = await this._handleOpenLink(request.params); + break; + + case 'ui/request-display-mode': + break; // not supported + + case 'ui/notifications/initialized': + break; + + case 'ui/message': + result = await this._handleUiMessage(request.params); + break; + + case 'notifications/message': + await this._mcpToolCallUI.log(request.params); + break; + + default: { + softAssertNever(request); + const cast = request as MCP.JSONRPCRequest; + if (cast.id !== undefined) { + await this._sendError(cast.id, -32601, `Method not found: ${cast.method}`); + } + return; + } + } + + // Send response if this was a request (has id) + if (hasKey(request, { id: true })) { + await this._sendResponse(request.id, result); + } + + } catch (error) { + this._logService.error(`[MCP App] Error handling ${request.method}:`, error); + if (hasKey(request, { id: true })) { + const message = error instanceof Error ? error.message : String(error); + await this._sendError(request.id, -32000, message); + } + } + } + + /** + * Handles the ui/initialize request from the MCP App. + */ + private async _handleInitialize(_params: McpApps.McpUiInitializeRequest['params']): Promise { + this._announcedCapabilities = true; + + // "Host MUST send this notification with the complete tool arguments after the Guest UI's initialize request completes" + // Cast to `any` due to https://github.com/modelcontextprotocol/ext-apps/issues/197 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let args: any; + try { + args = JSON.parse(this.renderData.input); + } catch { + args = this.renderData.input; + } + + const timeout = this._register(disposableTimeout(async () => { + this._store.delete(timeout); + await this._sendNotification({ + method: 'ui/notifications/tool-input', + params: { arguments: args } + }); + + if (this.toolInvocation.kind === 'toolInvocationSerialized') { + this._sendToolResult(this.toolInvocation.resultDetails); + } else if (this.toolInvocation.kind === 'toolInvocation') { + const invocation = this.toolInvocation; + this._register(autorunSelfDisposable(reader => { + const state = invocation.state.read(reader); + if (state.type === IChatToolInvocation.StateKind.Completed) { + this._sendToolResult(state.resultDetails); + reader.dispose(); + } + })); + } + })); + + return { + protocolVersion: McpApps.LATEST_PROTOCOL_VERSION, + hostInfo: { + name: this._productService.nameLong, + version: this._productService.version, + }, + hostCapabilities: { + openLinks: {}, + serverTools: { listChanged: true }, + serverResources: { listChanged: true }, + logging: {}, + }, + hostContext: this.hostContext.get(), + } satisfies Required; + } + + /** + * Sends the tool result notification when the result becomes available. + */ + private _sendToolResult(resultDetails: IToolResult['toolResultDetails'] | IChatToolInvocationSerialized['resultDetails']): void { + if (isToolResultInputOutputDetails(resultDetails) && resultDetails.mcpOutput) { + this._sendNotification({ + method: 'ui/notifications/tool-result', + params: resultDetails.mcpOutput as MCP.CallToolResult, + }); + } + } + + private async _handleUiMessage(params: McpApps.McpUiMessageRequest['params']): Promise { + const widget = this._chatWidgetService.getWidgetBySessionResource(this.renderData.sessionResource); + if (!widget) { + return { isError: true }; + } + + if (!isFalsyOrWhitespace(widget.getInput())) { + return { isError: true }; + } + + widget.setInput(params.content.filter(c => c.type === 'text').map(c => c.text).join('\n\n')); + widget.attachmentModel.clearAndSetContext(...params.content.map((c, i): IChatRequestVariableEntry | undefined => { + const id = `mcpui-${i}-${Date.now()}`; + if (c.type === 'image') { + return { kind: 'image', value: decodeBase64(c.data).buffer, id, name: 'Image' }; + } else if (c.type === 'resource_link') { + const uri = McpResourceURI.fromServer({ id: this.renderData.serverDefinitionId, label: '' }, c.uri); + return { kind: 'file', value: uri, id, name: basename(uri) }; + } else { + return undefined; + } + }).filter(isDefined)); + widget.focusInput(); + + return { isError: false }; + } + + private _handleSizeChanged(params: McpApps.McpUiSizeChangedNotification['params']): void { + if (params.height !== undefined) { + this._height = params.height; + this._onDidChangeHeight.fire(); + } + } + + private async _handleOpenLink(params: McpApps.McpUiOpenLinkRequest['params']): Promise { + const ok = await this._openerService.open(params.url); + return { isError: !ok }; + } + + /** + * Handles tools/call requests from the MCP App. + */ + private async _handleToolsCall(params: MCP.CallToolRequestParams, token: CancellationToken): Promise { + if (!params?.name) { + throw new Error('Missing tool name in tools/call request'); + } + + return this._mcpToolCallUI.callTool(params.name, params.arguments || {}, token); + } + + /** + * Handles resources/read requests from the MCP App. + */ + private async _handleResourcesRead(params: MCP.ReadResourceRequestParams, token: CancellationToken): Promise { + if (!params?.uri) { + throw new Error('Missing uri in resources/read request'); + } + + return this._mcpToolCallUI.readResource(params.uri, token); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async _sendResponse(id: number | string, result: any): Promise { + await this._webview.postMessage({ + jsonrpc: '2.0', + id, + result, + } satisfies MCP.JSONRPCResponse); + } + + private async _sendError(id: number | string, code: number, message: string): Promise { + await this._webview.postMessage({ + jsonrpc: '2.0', + id, + error: { code, message }, + } satisfies MCP.JSONRPCError); + } + + private async _sendNotification(message: McpApps.HostNotification): Promise { + await this._webview.postMessage({ + jsonrpc: '2.0', + ...message, + }); + } + + public override dispose(): void { + this._disposeCts.dispose(true); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts new file mode 100644 index 0000000000000..516d4ca01cd70 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../../base/browser/dom.js'; +import { Button } from '../../../../../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { Event } from '../../../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../../nls.js'; +import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { IMarkdownRendererService } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; +import { ChatErrorLevel, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; +import { IChatCodeBlockInfo } from '../../../chat.js'; +import { ChatErrorWidget } from '../chatErrorContentPart.js'; +import { ChatProgressSubPart } from '../chatProgressContentPart.js'; +import { ChatMcpAppModel, McpAppLoadState } from './chatMcpAppModel.js'; +import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; + +/** + * Data needed to render an MCP App, available before tool completion. + */ +export interface IMcpAppRenderData { + /** URI of the UI resource for rendering (e.g., "ui://weather-server/dashboard") */ + readonly resourceUri: string; + /** Reference to the server definition for reconnection */ + readonly serverDefinitionId: string; + /** Reference to the collection containing the server */ + readonly collectionId: string; + /** The tool input arguments as a JSON string */ + readonly input: string; + /** The session resource URI for the chat session */ + readonly sessionResource: URI; +} + + +/** + * Sub-part for rendering MCP App webviews in chat tool output. + * This is a thin view layer that delegates to ChatMcpAppModel. + */ +export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { + + public readonly domNode: HTMLElement; + public override readonly codeblocks: IChatCodeBlockInfo[] = []; + + /** The model that owns the webview */ + private readonly _model: ChatMcpAppModel; + + /** The webview container */ + private readonly _webviewContainer: HTMLElement; + + /** Current progress part for loading state */ + private readonly _progressPart = this._register(new MutableDisposable()); + + /** Current error node */ + private _errorNode: HTMLElement | undefined; + + constructor( + toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, + onDidRemount: Event, + private readonly _renderData: IMcpAppRenderData, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, + ) { + super(toolInvocation); + + // Create the DOM structure + this.domNode = dom.$('div.mcp-app-part'); + this._webviewContainer = dom.$('div.mcp-app-webview'); + this._webviewContainer.style.maxHeight = `${ChatMcpAppModel.maxWebviewHeightPct * 100}vh`; + this._webviewContainer.style.minHeight = '100px'; + this._webviewContainer.style.height = '300px'; // Initial height, will be updated by model + this.domNode.appendChild(this._webviewContainer); + + // Create the model - it will mount the webview to the container + this._model = this._register(this._instantiationService.createInstance( + ChatMcpAppModel, + toolInvocation, + this._renderData, + this._webviewContainer + )); + + // Update container height from model + this._updateContainerHeight(); + + // Set up load state handling + this._register(autorun(reader => { + const loadState = this._model.loadState.read(reader); + this._handleLoadStateChange(this._webviewContainer, loadState); + })); + + // Subscribe to model height changes + this._register(this._model.onDidChangeHeight(() => { + this._updateContainerHeight(); + this._onDidChangeHeight.fire(); + })); + + this._register(onDidRemount(() => { + this._model.remount(); + })); + } + + private _handleLoadStateChange(container: HTMLElement, loadState: McpAppLoadState): void { + // Remove any existing loading/error indicators + if (this._progressPart.value) { + this._progressPart.value.domNode.remove(); + } + this._progressPart.clear(); + if (this._errorNode) { + this._errorNode.remove(); + this._errorNode = undefined; + } + + switch (loadState.status) { + case 'loading': { + // Hide the webview container while loading + container.style.display = 'none'; + + const progressMessage = dom.$('span'); + progressMessage.textContent = localize('loadingMcpApp', 'Loading MCP App...'); + const progressPart = this._instantiationService.createInstance( + ChatProgressSubPart, + progressMessage, + ThemeIcon.modify(Codicon.loading, 'spin'), + undefined + ); + this._progressPart.value = progressPart; + // Append to domNode (parent), not the webview container + this.domNode.appendChild(progressPart.domNode); + break; + } + case 'loaded': { + // Show the webview container + container.style.display = ''; + this._onDidChangeHeight.fire(); + break; + } + case 'error': { + // Hide the webview container on error + container.style.display = 'none'; + this._showError(this.domNode, loadState.error); + break; + } + } + } + + private _updateContainerHeight(): void { + this._webviewContainer.style.height = `${this._model.height}px`; + } + + /** + * Shows an error message in the container. + */ + private _showError(container: HTMLElement, error: Error): void { + const errorNode = dom.$('.mcp-app-error'); + + // Create error message with markdown + const errorMessage = new MarkdownString(); + errorMessage.appendText(localize('mcpAppError', 'Error loading MCP App: {0}', error.message || String(error))); + + // Use ChatErrorWidget for consistent error styling + const errorWidget = new ChatErrorWidget(ChatErrorLevel.Error, errorMessage, this._markdownRendererService); + errorNode.appendChild(errorWidget.domNode); + + // Add retry button + const buttonContainer = dom.append(errorNode, dom.$('.chat-buttons-container')); + const retryButton = new Button(buttonContainer, defaultButtonStyles); + retryButton.label = localize('retry', 'Retry'); + retryButton.onDidClick(() => { + this._model.retry(); + }); + + container.appendChild(errorNode); + this._errorNode = errorNode; + this._onDidChangeHeight.fire(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index 3444afbdc55de..97ed68dc89662 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -6,10 +6,10 @@ import * as dom from '../../../../../../../base/browser/dom.js'; import { Emitter } from '../../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../../../../base/common/observable.js'; +import { autorun, derived } from '../../../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; import { IChatRendererContent } from '../../../../common/model/chatViewModel.js'; import { CodeBlockModelCollection } from '../../../../common/widget/codeBlockModelCollection.js'; import { isToolResultInputOutputDetails, isToolResultOutputDetails, ToolInvocationPresentation } from '../../../../common/tools/languageModelToolsService.js'; @@ -19,6 +19,7 @@ import { IChatContentPart, IChatContentPartRenderContext } from '../chatContentP import { CollapsibleListPool } from '../chatReferencesContentPart.js'; import { ExtensionsInstallConfirmationWidgetSubPart } from './chatExtensionsInstallToolSubPart.js'; import { ChatInputOutputMarkdownProgressPart } from './chatInputOutputMarkdownProgressPart.js'; +import { ChatMcpAppSubPart, IMcpAppRenderData } from './chatMcpAppSubPart.js'; import { ChatResultListSubPart } from './chatResultListSubPart.js'; import { ChatTerminalToolConfirmationSubPart } from './chatTerminalToolConfirmationSubPart.js'; import { ChatTerminalToolProgressPart } from './chatTerminalToolProgressPart.js'; @@ -35,7 +36,11 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa public readonly onDidChangeHeight = this._onDidChangeHeight.event; public get codeblocks(): IChatCodeBlockInfo[] { - return this.subPart?.codeblocks ?? []; + const codeblocks = this.subPart?.codeblocks ?? []; + if (this.mcpAppPart) { + codeblocks.push(...this.mcpAppPart.codeblocks); + } + return codeblocks; } public get codeblocksPartId(): string | undefined { @@ -43,6 +48,9 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa } private subPart!: BaseChatToolInvocationSubPart; + private mcpAppPart: ChatMcpAppSubPart | undefined; + + private readonly _onDidRemount = this._register(new Emitter()); constructor( private readonly toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, @@ -78,9 +86,12 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa // This part is a bit different, since IChatToolInvocation is not an immutable model object. So this part is able to rerender itself. // If this turns out to be a typical pattern, we could come up with a more reusable pattern, like telling the list to rerender an element // when the model changes, or trying to make the model immutable and swap out one content part for a new one based on user actions in the view. + // Note that `node.replaceWith` is used to ensure order is preserved when an mpc app is present. const partStore = this._register(new DisposableStore()); + let subPartDomNode: HTMLElement = document.createElement('div'); + this.domNode.appendChild(subPartDomNode); + const render = () => { - dom.clearNode(this.domNode); partStore.clear(); if (toolInvocation.presentation === ToolInvocationPresentation.HiddenAfterComplete && IChatToolInvocation.isComplete(toolInvocation)) { @@ -88,12 +99,44 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa } this.subPart = partStore.add(this.createToolInvocationSubPart()); - this.domNode.appendChild(this.subPart.domNode); + subPartDomNode.replaceWith(this.subPart.domNode); + subPartDomNode = this.subPart.domNode; + partStore.add(this.subPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); partStore.add(this.subPart.onNeedsRerender(render)); - this._onDidChangeHeight.fire(); }; + + const mcpAppRenderData = this.getMcpAppRenderData(); + if (mcpAppRenderData) { + const shouldRender = derived(r => { + const outcome = IChatToolInvocation.executionConfirmedOrDenied(toolInvocation, r); + return !!outcome && outcome.type !== ToolConfirmKind.Denied && outcome.type !== ToolConfirmKind.Skipped; + }); + + let appDomNode: HTMLElement = document.createElement('div'); + this.domNode.appendChild(appDomNode); + + this._register(autorun(r => { + if (shouldRender.read(r)) { + this.mcpAppPart = r.store.add(this.instantiationService.createInstance( + ChatMcpAppSubPart, + this.toolInvocation, + this._onDidRemount.event, + mcpAppRenderData + )); + appDomNode.replaceWith(this.mcpAppPart.domNode); + appDomNode = this.mcpAppPart.domNode; + r.store.add(this.mcpAppPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + } else { + this.mcpAppPart = undefined; + dom.clearNode(appDomNode); + } + + this._onDidChangeHeight.fire(); + })); + } + render(); } @@ -159,6 +202,35 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa return this.instantiationService.createInstance(ChatToolProgressSubPart, this.toolInvocation, this.context, this.renderer, this.announcedToolProgressKeys); } + /** + * Gets MCP App render data if this tool invocation has MCP App UI. + * Returns data from either: + * - toolSpecificData.mcpAppData (for in-progress tools) + * - result details mcpOutput (for completed tools) + */ + private getMcpAppRenderData(): IMcpAppRenderData | undefined { + const toolSpecificData = this.toolInvocation.toolSpecificData; + if (toolSpecificData?.kind === 'input' && toolSpecificData.mcpAppData) { + const rawInput = typeof toolSpecificData.rawInput === 'string' + ? toolSpecificData.rawInput + : JSON.stringify(toolSpecificData.rawInput, null, 2); + + return { + resourceUri: toolSpecificData.mcpAppData.resourceUri, + serverDefinitionId: toolSpecificData.mcpAppData.serverDefinitionId, + collectionId: toolSpecificData.mcpAppData.collectionId, + input: rawInput, + sessionResource: this.context.element.sessionResource, + }; + } + + return undefined; + } + + onDidRemount(): void { + this._onDidRemount.fire(); + } + hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { return (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && this.toolInvocation.toolCallId === other.toolCallId; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 89820667cf556..6a41a8438ec0a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -105,6 +105,12 @@ export interface IChatListItemTemplate { * they are disposed in a separate cycle after diffing with the next content to render. */ renderedParts?: IChatContentPart[]; + /** + * Whether the parts are mounted in the DOM. This is undefined after + * the element is disposed so the `renderedParts.onDidMount` can be + * called on the next render as appropriate. + */ + renderedPartsMounted?: boolean; readonly rowContainer: HTMLElement; readonly titleToolbar?: MenuWorkbenchToolBar; readonly header?: HTMLElement; @@ -663,6 +669,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + const alreadyRenderedPart = templateData.renderedParts?.[contentIndex]; + if (!partToRender) { // null=no change + if (!templateData.renderedPartsMounted) { + alreadyRenderedPart?.onDidRemount?.(); + } return; } - const alreadyRenderedPart = templateData.renderedParts?.[contentIndex]; - // keep existing thinking part instance during streaming and update it in place if (alreadyRenderedPart) { if (partToRender.kind === 'thinking' && alreadyRenderedPart instanceof ChatThinkingContentPart) { @@ -1751,6 +1761,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer, index: number, templateData: IChatListItemTemplate, details?: IListElementRenderDetails): void { this.traceLayout('disposeElement', `Disposing element, index=${index}`); templateData.elementDisposables.clear(); + templateData.renderedPartsMounted = false; if (templateData.currentElement && !this.viewModel?.editing) { this.templateDataByRequestId.delete(templateData.currentElement.id); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 51f62c610e514..34db52fbc8ea3 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -416,6 +416,15 @@ export interface IChatToolInputInvocationData { kind: 'input'; // eslint-disable-next-line @typescript-eslint/no-explicit-any rawInput: any; + /** Optional MCP App UI metadata for rendering during and after tool execution */ + mcpAppData?: { + /** URI of the UI resource for rendering (e.g., "ui://weather-server/dashboard") */ + resourceUri: string; + /** Reference to the server definition for reconnection */ + serverDefinitionId: string; + /** Reference to the collection containing the server */ + collectionId: string; + }; } export const enum ToolConfirmKind { diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 601142acc97a6..ebb0c2f618a53 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -184,6 +184,8 @@ export interface IToolResultInputOutputDetails { readonly input: string; readonly output: (ToolInputOutputEmbedded | ToolInputOutputReference)[]; readonly isError?: boolean; + /** Raw MCP tool result for MCP App UI rendering */ + readonly mcpOutput?: unknown; } export interface IToolResultOutputDetails { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpToolCallUI.ts b/src/vs/workbench/contrib/mcp/browser/mcpToolCallUI.ts new file mode 100644 index 0000000000000..e18a34d2e12ee --- /dev/null +++ b/src/vs/workbench/contrib/mcp/browser/mcpToolCallUI.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Gesture } from '../../../../base/browser/touch.js'; +import { decodeBase64 } from '../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { isMobile, isWeb, locale } from '../../../../base/common/platform.js'; +import { hasKey } from '../../../../base/common/types.js'; +import { ColorScheme } from '../../../../platform/theme/common/theme.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { McpServer } from '../common/mcpServer.js'; +import { IMcpServer, IMcpService, IMcpToolCallUIData, McpToolVisibility } from '../common/mcpTypes.js'; +import { findMcpServer, startServerAndWaitForLiveTools, translateMcpLogMessage } from '../common/mcpTypesUtils.js'; +import { MCP } from '../common/modelContextProtocol.js'; +import { McpApps } from '../common/modelContextProtocolApps.js'; + +/** + * Result from loading an MCP App UI resource. + */ +export interface IMcpAppResourceContent extends McpApps.McpUiResourceMeta { + /** The HTML content of the UI resource */ + readonly html: string; + /** MIME type of the content */ + readonly mimeType: string; +} + +/** + * Wrapper class that "upgrades" serializable IMcpToolCallUIData into a functional + * object that can load UI resources and proxy tool/resource calls back to the MCP server. + */ +export class McpToolCallUI extends Disposable { + /** + * Basic host context reflecting the current UI and theme. Notably lacks + * the `toolInfo` or `viewport` sizes. + */ + public readonly hostContext: IObservable; + + constructor( + private readonly _uiData: IMcpToolCallUIData, + @IMcpService private readonly _mcpService: IMcpService, + @IThemeService themeService: IThemeService, + ) { + super(); + + const colorTheme = observableFromEvent( + themeService.onDidColorThemeChange, + () => { + const type = themeService.getColorTheme().type; + return type === ColorScheme.DARK || type === ColorScheme.HIGH_CONTRAST_DARK ? 'dark' : 'light'; + } + ); + + this.hostContext = derived((reader): McpApps.McpUiHostContext => { + return { + theme: colorTheme.read(reader), + styles: { + variables: { + '--color-background-primary': 'var(--vscode-editor-background)', + '--color-background-secondary': 'var(--vscode-sideBar-background)', + '--color-background-tertiary': 'var(--vscode-activityBar-background)', + '--color-background-inverse': 'var(--vscode-editor-foreground)', + '--color-background-ghost': 'transparent', + '--color-background-info': 'var(--vscode-inputValidation-infoBackground)', + '--color-background-danger': 'var(--vscode-inputValidation-errorBackground)', + '--color-background-success': 'var(--vscode-diffEditor-insertedTextBackground)', + '--color-background-warning': 'var(--vscode-inputValidation-warningBackground)', + '--color-background-disabled': 'var(--vscode-editor-inactiveSelectionBackground)', + + '--color-text-primary': 'var(--vscode-foreground)', + '--color-text-secondary': 'var(--vscode-descriptionForeground)', + '--color-text-tertiary': 'var(--vscode-disabledForeground)', + '--color-text-inverse': 'var(--vscode-editor-background)', + '--color-text-info': 'var(--vscode-textLink-foreground)', + '--color-text-danger': 'var(--vscode-errorForeground)', + '--color-text-success': 'var(--vscode-testing-iconPassed)', + '--color-text-warning': 'var(--vscode-editorWarning-foreground)', + '--color-text-disabled': 'var(--vscode-disabledForeground)', + '--color-text-ghost': 'var(--vscode-descriptionForeground)', + + '--color-border-primary': 'var(--vscode-widget-border)', + '--color-border-secondary': 'var(--vscode-editorWidget-border)', + '--color-border-tertiary': 'var(--vscode-panel-border)', + '--color-border-inverse': 'var(--vscode-foreground)', + '--color-border-ghost': 'transparent', + '--color-border-info': 'var(--vscode-inputValidation-infoBorder)', + '--color-border-danger': 'var(--vscode-inputValidation-errorBorder)', + '--color-border-success': 'var(--vscode-testing-iconPassed)', + '--color-border-warning': 'var(--vscode-inputValidation-warningBorder)', + '--color-border-disabled': 'var(--vscode-disabledForeground)', + + '--color-ring-primary': 'var(--vscode-focusBorder)', + '--color-ring-secondary': 'var(--vscode-focusBorder)', + '--color-ring-inverse': 'var(--vscode-focusBorder)', + '--color-ring-info': 'var(--vscode-inputValidation-infoBorder)', + '--color-ring-danger': 'var(--vscode-inputValidation-errorBorder)', + '--color-ring-success': 'var(--vscode-testing-iconPassed)', + '--color-ring-warning': 'var(--vscode-inputValidation-warningBorder)', + + '--font-sans': 'var(--vscode-font-family)', + '--font-mono': 'var(--vscode-editor-font-family)', + + '--font-weight-normal': 'normal', + '--font-weight-medium': '500', + '--font-weight-semibold': '600', + '--font-weight-bold': 'bold', + + '--font-text-xs-size': '10px', + '--font-text-sm-size': '11px', + '--font-text-md-size': '13px', + '--font-text-lg-size': '14px', + + '--font-heading-xs-size': '16px', + '--font-heading-sm-size': '18px', + '--font-heading-md-size': '20px', + '--font-heading-lg-size': '24px', + '--font-heading-xl-size': '32px', + '--font-heading-2xl-size': '40px', + '--font-heading-3xl-size': '48px', + + '--border-radius-xs': '2px', + '--border-radius-sm': '3px', + '--border-radius-md': '4px', + '--border-radius-lg': '6px', + '--border-radius-xl': '8px', + '--border-radius-full': '9999px', + + '--border-width-regular': '1px', + + '--font-text-xs-line-height': '1.5', + '--font-text-sm-line-height': '1.5', + '--font-text-md-line-height': '1.5', + '--font-text-lg-line-height': '1.5', + + '--font-heading-xs-line-height': '1.25', + '--font-heading-sm-line-height': '1.25', + '--font-heading-md-line-height': '1.25', + '--font-heading-lg-line-height': '1.25', + '--font-heading-xl-line-height': '1.25', + '--font-heading-2xl-line-height': '1.25', + '--font-heading-3xl-line-height': '1.25', + + '--shadow-hairline': '0 0 0 1px var(--vscode-widget-shadow)', + '--shadow-sm': '0 1px 2px 0 var(--vscode-widget-shadow)', + '--shadow-md': '0 4px 6px -1px var(--vscode-widget-shadow)', + '--shadow-lg': '0 10px 15px -3px var(--vscode-widget-shadow)', + } + }, + displayMode: 'inline', + availableDisplayModes: ['inline'], + locale: locale, + platform: isWeb ? 'web' : isMobile ? 'mobile' : 'desktop', + deviceCapabilities: { + touch: Gesture.isTouchDevice(), + hover: Gesture.isHoverDevice(), + }, + }; + }); + } + + /** + * Gets the underlying UI data. + */ + public get uiData(): IMcpToolCallUIData { + return this._uiData; + } + + /** + * Logs a message to the MCP server's logger. + */ + public async log(log: MCP.LoggingMessageNotificationParams) { + const server = await this._getServer(CancellationToken.None); + if (server) { + translateMcpLogMessage((server as McpServer).logger, log, `[App UI]`); + } + } + + /** + * Gets or finds the MCP server for this UI. + */ + private async _getServer(token: CancellationToken): Promise { + return findMcpServer(this._mcpService, s => + s.definition.id === this._uiData.serverDefinitionId && + s.collection.id === this._uiData.collectionId, + token + ); + } + + /** + * Loads the UI resource from the MCP server. + * @param token Cancellation token + * @returns The HTML content and CSP configuration + */ + public async loadResource(token: CancellationToken): Promise { + const server = await this._getServer(token); + if (!server) { + throw new Error('MCP server not found for UI resource'); + } + + const resourceResult = await McpServer.callOn(server, h => h.readResource({ uri: this._uiData.resourceUri }, token), token); + if (!resourceResult.contents || resourceResult.contents.length === 0) { + throw new Error('UI resource not found on server'); + } + + const content = resourceResult.contents[0]; + let html: string; + const mimeType = content.mimeType || 'text/html'; + + if (hasKey(content, { text: true })) { + html = content.text; + } else if (hasKey(content, { blob: true })) { + html = decodeBase64(content.blob).toString(); + } else { + throw new Error('UI resource has no content'); + } + + const meta = resourceResult._meta?.ui as McpApps.McpUiResourceMeta | undefined; + + return { + ...meta, + html, + mimeType, + }; + } + + /** + * Calls a tool on the MCP server. + * @param name Tool name + * @param params Tool parameters + * @param token Cancellation token + * @returns The tool call result + */ + public async callTool(name: string, params: Record, token: CancellationToken): Promise { + const server = await this._getServer(token); + if (!server) { + throw new Error('MCP server not found for tool call'); + } + + await startServerAndWaitForLiveTools(server, undefined, token); + + const tool = server.tools.get().find(t => t.definition.name === name); + if (!tool || !(tool.visibility & McpToolVisibility.App)) { + throw new Error(`Tool not found on server: ${name}`); + } + + const res = await tool.call(params, undefined, token); + return { + content: res.content, + isError: res.isError, + _meta: res._meta, + structuredContent: res.structuredContent, + }; + } + + /** + * Reads a resource from the MCP server. + * @param uri Resource URI + * @param token Cancellation token + * @returns The resource content + */ + public async readResource(uri: string, token: CancellationToken): Promise { + const server = await this._getServer(token); + if (!server) { + throw new Error('MCP server not found'); + } + + return await McpServer.callOn(server, h => h.readResource({ uri }, token), token); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index 082e55958da2f..99ad550daad9e 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -12,12 +12,14 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } import { equals } from '../../../../base/common/objects.js'; import { autorun } from '../../../../base/common/observable.js'; import { basename } from '../../../../base/common/resources.js'; -import { isDefined } from '../../../../base/common/types.js'; +import { isDefined, Mutable } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IImageResizeService } from '../../../../platform/imageResize/common/imageResizeService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { mcpAppsEnabledConfig } from '../../../../platform/mcp/common/mcpManagement.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; @@ -25,7 +27,7 @@ import { ChatResponseResource, getAttachableImageExtension } from '../../chat/co import { LanguageModelPartAudience } from '../../chat/common/languageModels.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolConfirmationMessages, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, IToolResultInputOutputDetails, ToolDataSource, ToolProgress, ToolSet } from '../../chat/common/tools/languageModelToolsService.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; -import { IMcpServer, IMcpService, IMcpTool, IMcpToolResourceLinkContents, McpResourceURI, McpToolResourceLinkMimeType } from './mcpTypes.js'; +import { IMcpServer, IMcpService, IMcpTool, IMcpToolResourceLinkContents, McpResourceURI, McpToolResourceLinkMimeType, McpToolVisibility } from './mcpTypes.js'; import { mcpServerToSourceData } from './mcpTypesUtils.js'; interface ISyncedToolData { @@ -111,6 +113,11 @@ export class McpLanguageModelToolContribution extends Disposable implements IWor const collection = collectionObservable.read(reader); for (const tool of server.tools.read(reader)) { + // Skip app-only tools - they should not be registered with the language model tools service + if (!(tool.visibility & McpToolVisibility.Model)) { + continue; + } + const existing = tools.get(tool.id); const icons = tool.icons.getUrl(22); const toolData: IToolData = { @@ -176,6 +183,7 @@ class McpToolImplementation implements IToolImpl { constructor( private readonly _tool: IMcpTool, private readonly _server: IMcpServer, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IProductService private readonly _productService: IProductService, @IFileService private readonly _fileService: IFileService, @IImageResizeService private readonly _imageResizeService: IImageResizeService, @@ -205,6 +213,8 @@ class McpToolImplementation implements IToolImpl { confirm.confirmResults = true; } + const mcpUiEnabled = this._configurationService.getValue(mcpAppsEnabledConfig); + return { confirmationMessages: confirm, invocationMessage: new MarkdownString(localize('msg.run', "Running {0}", title)), @@ -212,7 +222,12 @@ class McpToolImplementation implements IToolImpl { originMessage: localize('msg.subtitle', "{0} (MCP Server)", server.definition.label), toolSpecificData: { kind: 'input', - rawInput: context.parameters + rawInput: context.parameters, + mcpAppData: mcpUiEnabled && tool.uiResourceUri ? { + resourceUri: tool.uiResourceUri, + serverDefinitionId: server.definition.id, + collectionId: server.collection.id, + } : undefined, } }; } @@ -224,7 +239,7 @@ class McpToolImplementation implements IToolImpl { }; const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionId: invocation.context?.sessionId }, token); - const details: IToolResultInputOutputDetails = { + const details: Mutable = { input: JSON.stringify(invocation.parameters, undefined, 2), output: [], isError: callResult.isError === true, @@ -341,6 +356,11 @@ class McpToolImplementation implements IToolImpl { result.content.push({ kind: 'text', value: JSON.stringify(callResult.structuredContent), audience: [LanguageModelPartAudience.Assistant] }); } + // Add raw MCP output for MCP App UI rendering if this tool has UI + if (this._tool.uiResourceUri) { + details.mcpOutput = callResult; + } + result.toolResultDetails = details; return result; } diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index cdd421ab2c6be..c3757f3696ed9 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -37,8 +37,9 @@ import { McpIcons, parseAndValidateMcpIcon, StoredMcpIcons } from './mcpIcons.js import { IMcpRegistry } from './mcpRegistryTypes.js'; import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; import { McpTaskManager } from './mcpTaskManager.js'; -import { ElicitationKind, extensionMcpCollectionPrefix, IMcpElicitationService, IMcpIcons, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpResourceTemplate, IMcpSamplingService, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, IMcpToolCallContext, McpCapability, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, mcpPromptReplaceSpecialChars, McpResourceURI, McpServerCacheState, McpServerDefinition, McpServerStaticToolAvailability, McpServerTransportType, McpToolName, MpcResponseError, UserInteractionRequiredError } from './mcpTypes.js'; +import { ElicitationKind, extensionMcpCollectionPrefix, IMcpElicitationService, IMcpIcons, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpResourceTemplate, IMcpSamplingService, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, IMcpToolCallContext, McpCapability, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, mcpPromptReplaceSpecialChars, McpResourceURI, McpServerCacheState, McpServerDefinition, McpServerStaticToolAvailability, McpServerTransportType, McpToolName, McpToolVisibility, MpcResponseError, UserInteractionRequiredError } from './mcpTypes.js'; import { MCP } from './modelContextProtocol.js'; +import { McpApps } from './modelContextProtocolApps.js'; import { UriTemplate } from './uriTemplate.js'; type ServerBootData = { @@ -218,6 +219,18 @@ type ValidatedMcpTool = MCP.Tool & { * in {@link McpServer._getValidatedTools}. */ serverToolName: string; + + /** + * Visibility of the tool, parsed from `_meta.ui.visibility`. + * Defaults to Model | App if not specified. + */ + visibility: McpToolVisibility; + + /** + * UI resource URI if this tool has an associated MCP App UI. + * Parsed from `_meta.ui.resourceUri`. + */ + uiResourceUri?: string; }; interface StoredServerMetadata { @@ -394,6 +407,10 @@ export class McpServer extends Disposable implements IMcpServer { return fromServerResult.data?.nonce === currentNonce() ? McpServerCacheState.Live : McpServerCacheState.Outdated; }); + public get logger(): ILogger { + return this._logger; + } + private readonly _loggerId: string; private readonly _logger: ILogger; private _lastModeDebugged = false; @@ -741,10 +758,28 @@ export class McpServer extends Disposable implements IMcpServer { } private async _normalizeTool(originalTool: MCP.Tool): Promise { + // Parse MCP Apps UI metadata from _meta.ui + const uiMeta = originalTool._meta?.ui as McpApps.McpUiToolMeta | undefined; + + // Compute visibility from _meta.ui.visibility, defaulting to Model | App + let visibility: McpToolVisibility = McpToolVisibility.Model | McpToolVisibility.App; + if (uiMeta?.visibility && Array.isArray(uiMeta.visibility)) { + visibility &= 0; + + if (uiMeta.visibility.includes('model')) { + visibility |= McpToolVisibility.Model; + } + if (uiMeta.visibility.includes('app')) { + visibility |= McpToolVisibility.App; + } + } + const tool: ValidatedMcpTool = { ...originalTool, serverToolName: originalTool.name, _icons: this._parseIcons(originalTool), + visibility, + uiResourceUri: uiMeta?.resourceUri, }; if (!tool.description) { // Ensure a description is provided for each tool, #243919 @@ -980,8 +1015,10 @@ export class McpTool implements IMcpTool { readonly id: string; readonly referenceName: string; readonly icons: IMcpIcons; + readonly visibility: McpToolVisibility; public get definition(): MCP.Tool { return this._definition; } + public get uiResourceUri(): string | undefined { return this._definition.uiResourceUri; } constructor( private readonly _server: McpServer, @@ -992,6 +1029,7 @@ export class McpTool implements IMcpTool { this.referenceName = _definition.name.replaceAll('.', '_'); this.id = (idPrefix + _definition.name).replaceAll('.', '_').slice(0, McpToolName.MaxLength); this.icons = McpIcons.fromStored(this._definition._icons); + this.visibility = _definition.visibility; } async call(params: Record, context?: IMcpToolCallContext, token?: CancellationToken): Promise { @@ -1052,6 +1090,7 @@ export class McpTool implements IMcpTool { // Wait for tools to refresh for dynamic servers (#261611) await this._server.awaitToolRefresh(); + return result; } catch (err) { // Handle URL elicitation required error diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts index 121ec51db99ab..d7e0ed232f722 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts @@ -18,7 +18,7 @@ import { IProductService } from '../../../../platform/product/common/productServ import { IMcpMessageTransport } from './mcpRegistryTypes.js'; import { IMcpTaskInternal, McpTaskManager } from './mcpTaskManager.js'; import { IMcpClientMethods, McpConnectionState, McpError, MpcResponseError } from './mcpTypes.js'; -import { isTaskResult } from './mcpTypesUtils.js'; +import { isTaskResult, translateMcpLogMessage } from './mcpTypesUtils.js'; import { MCP } from './modelContextProtocol.js'; /** @@ -454,32 +454,7 @@ export class McpServerRequestHandler extends Disposable { } private handleLoggingNotification(request: MCP.LoggingMessageNotification): void { - let contents = typeof request.params.data === 'string' ? request.params.data : JSON.stringify(request.params.data); - if (request.params.logger) { - contents = `${request.params.logger}: ${contents}`; - } - - switch (request.params?.level) { - case 'debug': - this.logger.debug(contents); - break; - case 'info': - case 'notice': - this.logger.info(contents); - break; - case 'warning': - this.logger.warn(contents); - break; - case 'error': - case 'critical': - case 'alert': - case 'emergency': - this.logger.error(contents); - break; - default: - this.logger.info(contents); - break; - } + translateMcpLogMessage(this.logger, request.params); } /** diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 81b228991a18b..9a38b10c35fff 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -438,6 +438,30 @@ export interface IMcpToolCallContext { chatRequestId?: string; } +/** + * Visibility of an MCP tool, based on the MCP Apps `_meta.ui.visibility` field. + * @see https://github.com/anthropics/mcp/blob/main/apps.md + */ +export const enum McpToolVisibility { + /** Tool is visible to and callable by the language model */ + Model = 1 << 0, + /** Tool is callable by the MCP App UI */ + App = 1 << 1, +} + +/** + * Serializable data for MCP App UI rendering. + * This contains all the information needed to render an MCP App webview. + */ +export interface IMcpToolCallUIData { + /** URI of the UI resource for rendering (e.g., "ui://weather-server/dashboard") */ + readonly resourceUri: string; + /** Reference to the server definition for reconnection */ + readonly serverDefinitionId: string; + /** Reference to the collection containing the server */ + readonly collectionId: string; +} + export interface IMcpTool { readonly id: string; @@ -445,6 +469,10 @@ export interface IMcpTool { readonly referenceName: string; readonly icons: IMcpIcons; readonly definition: MCP.Tool; + /** Visibility of the tool (Model, App, or both). Defaults to Model | App. */ + readonly visibility: McpToolVisibility; + /** Optional UI resource URI for MCP App rendering */ + readonly uiResourceUri?: string; /** * Calls a tool diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypesUtils.ts b/src/vs/workbench/contrib/mcp/common/mcpTypesUtils.ts index 450fdab6a22bc..cdd61709f5927 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypesUtils.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypesUtils.ts @@ -7,7 +7,8 @@ import { disposableTimeout, timeout } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun, IReader } from '../../../../base/common/observable.js'; +import { autorun, autorunSelfDisposable, IReader } from '../../../../base/common/observable.js'; +import { ILogger } from '../../../../platform/log/common/log.js'; import { ToolDataSource } from '../../chat/common/tools/languageModelToolsService.js'; import { IMcpServer, IMcpServerStartOpts, IMcpService, McpConnectionState, McpServerCacheState, McpServerTransportType } from './mcpTypes.js'; import { MCP } from './modelContextProtocol.js'; @@ -118,3 +119,61 @@ export function canLoadMcpNetworkResourceDirectly(resource: URL, server: IMcpSer export function isTaskResult(obj: MCP.Result | MCP.CreateTaskResult): obj is MCP.CreateTaskResult { return (obj as MCP.CreateTaskResult).task !== undefined; } + +export function findMcpServer(mcpService: IMcpService, filter: (s: IMcpServer) => boolean, token?: CancellationToken) { + return new Promise((resolve) => { + autorunSelfDisposable(reader => { + if (token) { + if (token.isCancellationRequested) { + reader.dispose(); + resolve(undefined); + return; + } + + reader.store.add(token.onCancellationRequested(() => { + reader.dispose(); + resolve(undefined); + })); + } + + const servers = mcpService.servers.read(reader); + const server = servers.find(filter); + if (server) { + resolve(server); + reader.dispose(); + } + }); + }); +} + +export function translateMcpLogMessage(logger: ILogger, params: MCP.LoggingMessageNotificationParams, prefix = '') { + let contents = typeof params.data === 'string' ? params.data : JSON.stringify(params.data); + if (params.logger) { + contents = `${params.logger}: ${contents}`; + } + if (prefix) { + contents = `${prefix} ${contents}`; + } + + switch (params?.level) { + case 'debug': + logger.debug(contents); + break; + case 'info': + case 'notice': + logger.info(contents); + break; + case 'warning': + logger.warn(contents); + break; + case 'error': + case 'critical': + case 'alert': + case 'emergency': + logger.error(contents); + break; + default: + logger.info(contents); + break; + } +} diff --git a/src/vs/workbench/contrib/mcp/common/modelContextProtocolApps.ts b/src/vs/workbench/contrib/mcp/common/modelContextProtocolApps.ts new file mode 100644 index 0000000000000..fa16f72378b2f --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/modelContextProtocolApps.ts @@ -0,0 +1,608 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MCP } from './modelContextProtocol.js'; + +type CallToolResult = MCP.CallToolResult; +type ContentBlock = MCP.ContentBlock; +type Implementation = MCP.Implementation; +type RequestId = MCP.RequestId; +type Tool = MCP.Tool; + +//#region utilities + +export namespace McpApps { + export type AppRequest = + | MCP.CallToolRequest + | MCP.ReadResourceRequest + | MCP.PingRequest + | (McpUiOpenLinkRequest & MCP.JSONRPCRequest) + | (McpUiMessageRequest & MCP.JSONRPCRequest) + | (McpUiRequestDisplayModeRequest & MCP.JSONRPCRequest) + | (McpApps.McpUiInitializeRequest & MCP.JSONRPCRequest); + + export type AppNotification = + | McpUiInitializedNotification + | McpUiSizeChangedNotification + | MCP.LoggingMessageNotification; + + export type AppMessage = AppRequest | AppNotification; + + export type HostResult = + | MCP.CallToolResult + | MCP.ReadResourceResult + | MCP.EmptyResult + | McpApps.McpUiInitializeResult + | McpUiMessageResult + | McpUiOpenLinkResult + | McpUiRequestDisplayModeResult; + + export type HostNotification = + | McpUiHostContextChangedNotification + | McpUiResourceTeardownRequest + | McpUiToolInputNotification + | McpUiToolInputPartialNotification + | McpUiToolResultNotification + | McpUiToolCancelledNotification + | McpUiSizeChangedNotification; + + export type HostMessage = HostResult | HostNotification; +} + +/* eslint-disable local/code-no-unexternalized-strings */ + + +/** + * Schema updated from the Model Context Protocol Apps repository at + * https://github.com/modelcontextprotocol/ext-apps/blob/main/src/spec.types.ts + * + * ⚠️ Do not edit within `namespace` manually except to update schema versions ⚠️ + */ +export namespace McpApps { + + + /** + * MCP Apps Protocol Types (spec.types.ts) + * + * This file contains pure TypeScript interface definitions for the MCP Apps protocol. + * These types are the source of truth and are used to generate Zod schemas via ts-to-zod. + * + * - Use `@description` JSDoc tags to generate `.describe()` calls on schemas + * - Run `npm run generate:schemas` to regenerate schemas from these types + * + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx + */ + + /** + * Current protocol version supported by this SDK. + * + * The SDK automatically handles version negotiation during initialization. + * Apps and hosts don't need to manage protocol versions manually. + */ + export const LATEST_PROTOCOL_VERSION = "2025-11-21"; + + /** + * @description Color theme preference for the host environment. + */ + export type McpUiTheme = "light" | "dark"; + + /** + * @description Display mode for UI presentation. + */ + export type McpUiDisplayMode = "inline" | "fullscreen" | "pip"; + + /** + * @description CSS variable keys available to MCP apps for theming. + */ + export type McpUiStyleVariableKey = + // Background colors + | "--color-background-primary" + | "--color-background-secondary" + | "--color-background-tertiary" + | "--color-background-inverse" + | "--color-background-ghost" + | "--color-background-info" + | "--color-background-danger" + | "--color-background-success" + | "--color-background-warning" + | "--color-background-disabled" + // Text colors + | "--color-text-primary" + | "--color-text-secondary" + | "--color-text-tertiary" + | "--color-text-inverse" + | "--color-text-info" + | "--color-text-danger" + | "--color-text-success" + | "--color-text-warning" + | "--color-text-disabled" + | "--color-text-ghost" + // Border colors + | "--color-border-primary" + | "--color-border-secondary" + | "--color-border-tertiary" + | "--color-border-inverse" + | "--color-border-ghost" + | "--color-border-info" + | "--color-border-danger" + | "--color-border-success" + | "--color-border-warning" + | "--color-border-disabled" + // Ring colors + | "--color-ring-primary" + | "--color-ring-secondary" + | "--color-ring-inverse" + | "--color-ring-info" + | "--color-ring-danger" + | "--color-ring-success" + | "--color-ring-warning" + // Typography - Family + | "--font-sans" + | "--font-mono" + // Typography - Weight + | "--font-weight-normal" + | "--font-weight-medium" + | "--font-weight-semibold" + | "--font-weight-bold" + // Typography - Text Size + | "--font-text-xs-size" + | "--font-text-sm-size" + | "--font-text-md-size" + | "--font-text-lg-size" + // Typography - Heading Size + | "--font-heading-xs-size" + | "--font-heading-sm-size" + | "--font-heading-md-size" + | "--font-heading-lg-size" + | "--font-heading-xl-size" + | "--font-heading-2xl-size" + | "--font-heading-3xl-size" + // Typography - Text Line Height + | "--font-text-xs-line-height" + | "--font-text-sm-line-height" + | "--font-text-md-line-height" + | "--font-text-lg-line-height" + // Typography - Heading Line Height + | "--font-heading-xs-line-height" + | "--font-heading-sm-line-height" + | "--font-heading-md-line-height" + | "--font-heading-lg-line-height" + | "--font-heading-xl-line-height" + | "--font-heading-2xl-line-height" + | "--font-heading-3xl-line-height" + // Border radius + | "--border-radius-xs" + | "--border-radius-sm" + | "--border-radius-md" + | "--border-radius-lg" + | "--border-radius-xl" + | "--border-radius-full" + // Border width + | "--border-width-regular" + // Shadows + | "--shadow-hairline" + | "--shadow-sm" + | "--shadow-md" + | "--shadow-lg"; + + /** + * @description Style variables for theming MCP apps. + * + * Individual style keys are optional - hosts may provide any subset of these values. + * Values are strings containing CSS values (colors, sizes, font stacks, etc.). + * + * Note: This type uses `Record` rather than `Partial>` + * for compatibility with Zod schema generation. Both are functionally equivalent for validation. + */ + export type McpUiStyles = Record; + + /** + * @description Request to open an external URL in the host's default browser. + * @see {@link app.App.sendOpenLink} for the method that sends this request + */ + export interface McpUiOpenLinkRequest { + method: "ui/open-link"; + params: { + /** @description URL to open in the host's browser */ + url: string; + }; + } + + /** + * @description Result from opening a URL. + * @see {@link McpUiOpenLinkRequest} + */ + export interface McpUiOpenLinkResult { + /** @description True if the host failed to open the URL (e.g., due to security policy). */ + isError?: boolean; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The schema intentionally omits this to enforce strict validation. + */ + [key: string]: unknown; + } + + /** + * @description Request to send a message to the host's chat interface. + * @see {@link app.App.sendMessage} for the method that sends this request + */ + export interface McpUiMessageRequest { + method: "ui/message"; + params: { + /** @description Message role, currently only "user" is supported. */ + role: "user"; + /** @description Message content blocks (text, image, etc.). */ + content: ContentBlock[]; + }; + } + + /** + * @description Result from sending a message. + * @see {@link McpUiMessageRequest} + */ + export interface McpUiMessageResult { + /** @description True if the host rejected or failed to deliver the message. */ + isError?: boolean; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The schema intentionally omits this to enforce strict validation. + */ + [key: string]: unknown; + } + + /** + * @description Notification that the sandbox proxy iframe is ready to receive content. + * @internal + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy + */ + export interface McpUiSandboxProxyReadyNotification { + method: "ui/notifications/sandbox-proxy-ready"; + params: {}; + } + + /** + * @description Notification containing HTML resource for the sandbox proxy to load. + * @internal + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy + */ + export interface McpUiSandboxResourceReadyNotification { + method: "ui/notifications/sandbox-resource-ready"; + params: { + /** @description HTML content to load into the inner iframe. */ + html: string; + /** @description Optional override for the inner iframe's sandbox attribute. */ + sandbox?: string; + /** @description CSP configuration from resource metadata. */ + csp?: { + /** @description Origins for network requests (fetch/XHR/WebSocket). */ + connectDomains?: string[]; + /** @description Origins for static resources (scripts, images, styles, fonts). */ + resourceDomains?: string[]; + }; + }; + } + + /** + * @description Notification of UI size changes (bidirectional: Guest <-> Host). + * @see {@link app.App.sendSizeChanged} for the method to send this from Guest UI + */ + export interface McpUiSizeChangedNotification { + method: "ui/notifications/size-changed"; + params: { + /** @description New width in pixels. */ + width?: number; + /** @description New height in pixels. */ + height?: number; + }; + } + + /** + * @description Notification containing complete tool arguments (Host -> Guest UI). + */ + export interface McpUiToolInputNotification { + method: "ui/notifications/tool-input"; + params: { + /** @description Complete tool call arguments as key-value pairs. */ + arguments?: Record; + }; + } + + /** + * @description Notification containing partial/streaming tool arguments (Host -> Guest UI). + */ + export interface McpUiToolInputPartialNotification { + method: "ui/notifications/tool-input-partial"; + params: { + /** @description Partial tool call arguments (incomplete, may change). */ + arguments?: Record; + }; + } + + /** + * @description Notification containing tool execution result (Host -> Guest UI). + */ + export interface McpUiToolResultNotification { + method: "ui/notifications/tool-result"; + /** @description Standard MCP tool execution result. */ + params: CallToolResult; + } + + /** + * @description Notification that tool execution was cancelled (Host -> Guest UI). + * Host MUST send this if tool execution was cancelled for any reason (user action, + * sampling error, classifier intervention, etc.). + */ + export interface McpUiToolCancelledNotification { + method: "ui/notifications/tool-cancelled"; + params: { + /** @description Optional reason for the cancellation (e.g., "user action", "timeout"). */ + reason?: string; + }; + } + + /** + * @description CSS blocks that can be injected by apps. + */ + export interface McpUiHostCss { + /** @description CSS for font loading (@font-face rules or @import statements). Apps must apply using applyHostFonts(). */ + fonts?: string; + } + + /** + * @description Style configuration for theming MCP apps. + */ + export interface McpUiHostStyles { + /** @description CSS variables for theming the app. */ + variables?: McpUiStyles; + /** @description CSS blocks that apps can inject. */ + css?: McpUiHostCss; + } + + /** + * @description Rich context about the host environment provided to Guest UIs. + */ + export interface McpUiHostContext { + /** @description Allow additional properties for forward compatibility. */ + [key: string]: unknown; + /** @description Metadata of the tool call that instantiated this App. */ + toolInfo?: { + /** @description JSON-RPC id of the tools/call request. */ + id: RequestId; + /** @description Tool definition including name, inputSchema, etc. */ + tool: Tool; + }; + /** @description Current color theme preference. */ + theme?: McpUiTheme; + /** @description Style configuration for theming the app. */ + styles?: McpUiHostStyles; + /** @description How the UI is currently displayed. */ + displayMode?: McpUiDisplayMode; + /** @description Display modes the host supports. */ + availableDisplayModes?: string[]; + /** @description Current and maximum dimensions available to the UI. */ + viewport?: { + /** @description Current viewport width in pixels. */ + width: number; + /** @description Current viewport height in pixels. */ + height: number; + /** @description Maximum available height in pixels (if constrained). */ + maxHeight?: number; + /** @description Maximum available width in pixels (if constrained). */ + maxWidth?: number; + }; + /** @description User's language and region preference in BCP 47 format. */ + locale?: string; + /** @description User's timezone in IANA format. */ + timeZone?: string; + /** @description Host application identifier. */ + userAgent?: string; + /** @description Platform type for responsive design decisions. */ + platform?: "web" | "desktop" | "mobile"; + /** @description Device input capabilities. */ + deviceCapabilities?: { + /** @description Whether the device supports touch input. */ + touch?: boolean; + /** @description Whether the device supports hover interactions. */ + hover?: boolean; + }; + /** @description Mobile safe area boundaries in pixels. */ + safeAreaInsets?: { + /** @description Top safe area inset in pixels. */ + top: number; + /** @description Right safe area inset in pixels. */ + right: number; + /** @description Bottom safe area inset in pixels. */ + bottom: number; + /** @description Left safe area inset in pixels. */ + left: number; + }; + } + + /** + * @description Notification that host context has changed (Host -> Guest UI). + * @see {@link McpUiHostContext} for the full context structure + */ + export interface McpUiHostContextChangedNotification { + method: "ui/notifications/host-context-changed"; + /** @description Partial context update containing only changed fields. */ + params: McpUiHostContext; + } + + /** + * @description Request for graceful shutdown of the Guest UI (Host -> Guest UI). + * @see {@link app-bridge.AppBridge.teardownResource} for the host method that sends this + */ + export interface McpUiResourceTeardownRequest { + method: "ui/resource-teardown"; + params: {}; + } + + /** + * @description Result from graceful shutdown request. + * @see {@link McpUiResourceTeardownRequest} + */ + export interface McpUiResourceTeardownResult { + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + */ + [key: string]: unknown; + } + + /** + * @description Capabilities supported by the host application. + * @see {@link McpUiInitializeResult} for the initialization result that includes these capabilities + */ + export interface McpUiHostCapabilities { + /** @description Experimental features (structure TBD). */ + experimental?: {}; + /** @description Host supports opening external URLs. */ + openLinks?: {}; + /** @description Host can proxy tool calls to the MCP server. */ + serverTools?: { + /** @description Host supports tools/list_changed notifications. */ + listChanged?: boolean; + }; + /** @description Host can proxy resource reads to the MCP server. */ + serverResources?: { + /** @description Host supports resources/list_changed notifications. */ + listChanged?: boolean; + }; + /** @description Host accepts log messages. */ + logging?: {}; + } + + /** + * @description Capabilities provided by the Guest UI (App). + * @see {@link McpUiInitializeRequest} for the initialization request that includes these capabilities + */ + export interface McpUiAppCapabilities { + /** @description Experimental features (structure TBD). */ + experimental?: {}; + /** @description App exposes MCP-style tools that the host can call. */ + tools?: { + /** @description App supports tools/list_changed notifications. */ + listChanged?: boolean; + }; + } + + /** + * @description Initialization request sent from Guest UI to Host. + * @see {@link app.App.connect} for the method that sends this request + */ + export interface McpUiInitializeRequest { + method: "ui/initialize"; + params: { + /** @description App identification (name and version). */ + appInfo: Implementation; + /** @description Features and capabilities this app provides. */ + appCapabilities: McpUiAppCapabilities; + /** @description Protocol version this app supports. */ + protocolVersion: string; + }; + } + + /** + * @description Initialization result returned from Host to Guest UI. + * @see {@link McpUiInitializeRequest} + */ + export interface McpUiInitializeResult { + /** @description Negotiated protocol version string (e.g., "2025-11-21"). */ + protocolVersion: string; + /** @description Host application identification and version. */ + hostInfo: Implementation; + /** @description Features and capabilities provided by the host. */ + hostCapabilities: McpUiHostCapabilities; + /** @description Rich context about the host environment. */ + hostContext: McpUiHostContext; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The schema intentionally omits this to enforce strict validation. + */ + [key: string]: unknown; + } + + /** + * @description Notification that Guest UI has completed initialization (Guest UI -> Host). + * @see {@link app.App.connect} for the method that sends this notification + */ + export interface McpUiInitializedNotification { + method: "ui/notifications/initialized"; + params?: {}; + } + + /** + * @description Content Security Policy configuration for UI resources. + */ + export interface McpUiResourceCsp { + /** @description Origins for network requests (fetch/XHR/WebSocket). */ + connectDomains?: string[]; + /** @description Origins for static resources (scripts, images, styles, fonts). */ + resourceDomains?: string[]; + } + + /** + * @description UI Resource metadata for security and rendering configuration. + */ + export interface McpUiResourceMeta { + /** @description Content Security Policy configuration. */ + csp?: McpUiResourceCsp; + /** @description Dedicated origin for widget sandbox. */ + domain?: string; + /** @description Visual boundary preference - true if UI prefers a visible border. */ + prefersBorder?: boolean; + } + + /** + * @description Request to change the display mode of the UI. + * The host will respond with the actual display mode that was set, + * which may differ from the requested mode if not supported. + * @see {@link app.App.requestDisplayMode} for the method that sends this request + */ + export interface McpUiRequestDisplayModeRequest { + method: "ui/request-display-mode"; + params: { + /** @description The display mode being requested. */ + mode: McpUiDisplayMode; + }; + } + + /** + * @description Result from requesting a display mode change. + * @see {@link McpUiRequestDisplayModeRequest} + */ + export interface McpUiRequestDisplayModeResult { + /** @description The display mode that was actually set. May differ from requested if not supported. */ + mode: McpUiDisplayMode; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The schema intentionally omits this to enforce strict validation. + */ + [key: string]: unknown; + } + + /** + * @description Tool visibility scope - who can access the tool. + */ + export type McpUiToolVisibility = "model" | "app"; + + /** + * @description UI-related metadata for tools. + */ + export interface McpUiToolMeta { + /** + * URI of the UI resource to display for this tool. + * This is converted to `_meta["ui/resourceUri"]`. + * + * @example "ui://weather/widget.html" + */ + resourceUri: string; + /** + * @description Who can access this tool. Default: ["model", "app"] + * - "model": Tool visible to and callable by the agent + * - "app": Tool callable by the app from this server only + */ + visibility?: McpUiToolVisibility[]; + } +}