chat: support MCP apps (#285864)

* wip

* wip

* wip

* works

* add a config option

* copilot comments

* simplify and polish
This commit is contained in:
Connor Peet
2026-01-07 11:39:38 -08:00
committed by GitHub
parent 84ce6e1a98
commit ec711a3d38
18 changed files with 1902 additions and 44 deletions

View File

@@ -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();

View File

@@ -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;

View File

@@ -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')),

View File

@@ -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;
}

View File

@@ -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,
));

View File

@@ -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<McpAppLoadState>(this, { status: 'loading' });
public readonly loadState: IObservable<McpAppLoadState> = this._loadState;
/** Event fired when height changes */
private readonly _onDidChangeHeight = this._register(new Emitter<void>());
public readonly onDidChangeHeight: Event<void> = this._onDidChangeHeight.event;
/** Host context observable combining tool call UI context with viewport */
private readonly _viewportObs = observableValue<Required<McpApps.McpUiHostContext['viewport']>>(this, undefined);
/** Full host context for the MCP App */
public readonly hostContext: IObservable<McpApps.McpUiHostContext>;
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<void> {
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
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 = `<meta http-equiv="Content-Security-Policy" content="${cspContent}">`;
// 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 = `
<script>(() => {
const api = acquireVsCodeApi();
const wrap = target => new Proxy(target, {
get: (obj, prop) => {
if (prop === 'postMessage') {
return (message, transfer) => api.postMessage(message, transfer);
}
return obj[prop];
},
});
window.parent = wrap(window.parent);
window.top = wrap(window.top);
})();</script>
`;
return this._prependToHead(html, cspTag + postMessageRehoist);
}
private _prependToHead(html: string, content: string): string {
// Try to inject into <head>
const headMatch = html.match(/<head[^>]*>/i);
if (headMatch) {
const insertIndex = headMatch.index! + headMatch[0].length;
return html.slice(0, insertIndex) + '\n' + content + html.slice(insertIndex);
}
// If no <head>, try to inject after <html>
const htmlMatch = html.match(/<html[^>]*>/i);
if (htmlMatch) {
const insertIndex = htmlMatch.index! + htmlMatch[0].length;
return html.slice(0, insertIndex) + '\n<head>' + content + '</head>' + html.slice(insertIndex);
}
// If no <html>, prepend
return `<!DOCTYPE html><html><head>${content}</head><body>${html}</body></html>`;
}
/**
* Handles incoming JSON-RPC messages from the webview.
*/
private async _handleWebviewMessage(message: McpApps.AppMessage): Promise<void> {
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<McpApps.McpUiInitializeResult> {
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<McpApps.McpUiInitializeResult>;
}
/**
* 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<McpApps.McpUiMessageResult> {
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<McpApps.McpUiOpenLinkResult> {
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<MCP.CallToolResult> {
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<MCP.ReadResourceResult> {
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<void> {
await this._webview.postMessage({
jsonrpc: '2.0',
id,
result,
} satisfies MCP.JSONRPCResponse);
}
private async _sendError(id: number | string, code: number, message: string): Promise<void> {
await this._webview.postMessage({
jsonrpc: '2.0',
id,
error: { code, message },
} satisfies MCP.JSONRPCError);
}
private async _sendNotification(message: McpApps.HostNotification): Promise<void> {
await this._webview.postMessage({
jsonrpc: '2.0',
...message,
});
}
public override dispose(): void {
this._disposeCts.dispose(true);
super.dispose();
}
}

View File

@@ -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<ChatProgressSubPart>());
/** Current error node */
private _errorNode: HTMLElement | undefined;
constructor(
toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized,
onDidRemount: Event<void>,
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();
}
}

View File

@@ -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<void>());
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;
}

View File

@@ -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<Ch
this.renderChatRequest(element, index, templateData);
}
}
templateData.renderedPartsMounted = true;
}
private renderDetail(element: IChatResponseViewModel, templateData: IChatListItemTemplate): void {
@@ -992,13 +999,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
const renderedParts = templateData.renderedParts ?? [];
templateData.renderedParts = renderedParts;
partsToRender.forEach((partToRender, contentIndex) => {
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<Ch
disposeElement(node: ITreeNode<ChatTreeItem, FuzzyScore>, 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);

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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<McpApps.McpUiHostContext>;
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<IMcpServer | undefined> {
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<IMcpAppResourceContent> {
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<string, unknown>, token: CancellationToken): Promise<MCP.CallToolResult> {
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<MCP.ReadResourceResult> {
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);
}
}

View File

@@ -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<boolean>(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<string, unknown>, progress, { chatRequestId: invocation.chatRequestId, chatSessionId: invocation.context?.sessionId }, token);
const details: IToolResultInputOutputDetails = {
const details: Mutable<IToolResultInputOutputDetails> = {
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;
}

View File

@@ -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<ValidatedMcpTool | { error: string[] }> {
// 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<string, unknown>, context?: IMcpToolCallContext, token?: CancellationToken): Promise<MCP.CallToolResult> {
@@ -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

View File

@@ -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);
}
/**

View File

@@ -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

View File

@@ -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<IMcpServer | undefined>((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;
}
}

View File

@@ -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<K, string | undefined>` rather than `Partial<Record<K, string>>`
* for compatibility with Zod schema generation. Both are functionally equivalent for validation.
*/
export type McpUiStyles = Record<McpUiStyleVariableKey, string | undefined>;
/**
* @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<string, unknown>;
};
}
/**
* @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<string, unknown>;
};
}
/**
* @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[];
}
}