ext-apps/examples/basic-host/src/implementation.ts
Anton Pidkuiko 37aab03de0
docs: add tool visibility and restructure _meta.ui (#131)
* docs: add tool visibility and restructure _meta.ui

- Restructure tool metadata: `_meta["ui/resourceUri"]` → `_meta.ui.resourceUri`
- Add `visibility` array field: ["model"], ["apps"], or ["model", "apps"]
- Default ["model"] preserves standard MCP behavior
- ["apps"] enables widget-only tools hidden from agent
- Add McpUiToolMeta interface for type safety
- Add Design Decision #4 explaining approach vs OpenAI's two-field model

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* address PR review comments

- Change default visibility to ["model", "apps"]
- Add deprecation notice for flat ui/resourceUri format
- Add McpUiToolMeta and McpUiToolVisibility to spec.types.ts
- Improve tools/list and tools/call behavior wording

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: regenerate schemas for McpUiToolMeta types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: tool visibility and nested _meta.ui format

- Rename visibility "apps" → "app" in McpUiToolVisibility type
- Add _meta.ui.resourceUri nested format (deprecate flat format)
- Add getToolUiResourceUri() utility with backward compatibility
- Add visibility demo to system-monitor-server:
  - get-system-stats: visibility ["model"] with resourceUri
  - refresh-stats: visibility ["app"] (app-only polling)
- Update all example servers to use new _meta.ui format
- Add 11 unit tests for getToolUiResourceUri()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: regenerate schemas with updated dependencies

* feat: export McpUiToolMeta type and add type annotations

- Export McpUiToolMeta and McpUiToolVisibility types from types.ts
- Export corresponding Zod schemas (McpUiToolMetaSchema, McpUiToolVisibilitySchema)
- Add `as McpUiToolMeta` type annotations to all example servers
- Update docs/quickstart.md with proper typing

Ensures type safety for `_meta.ui` tool metadata across the codebase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add server helpers and optional connect() transport

Add `src/server/` with convenience functions for registering MCP App tools
and resources:

- `registerAppTool(server, name, config, handler)`
- `registerAppResource(server, name, uri, config, callback)`

The `transport` parameter in `App.connect()` is now optional, defaulting to
`PostMessageTransport(window.parent)`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update src/server/index.ts

Co-authored-by: Jonathan Hefner <jonathan@hefner.pro>

* feat: update McpUiAppToolConfig to support both _meta.ui and flat formats

* refactor: simplify server helper imports

* docs: add deprecation notice and improve resourceUri documentation

* feat: add backward compat normalization in registerAppTool

- If _meta.ui.resourceUri is set, also set legacy flat key
- If legacy flat key is set, also set _meta.ui.resourceUri
- Preserves existing visibility when merging
- Does not overwrite if both formats already set

* refactor: avoid mutating config arg in registerAppTool

* style: format with prettier

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Olivier Chafik <ochafik@anthropic.com>
Co-authored-by: Jonathan Hefner <jonathan@hefner.pro>
2025-12-16 21:47:40 +00:00

273 lines
9 KiB
TypeScript

import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html");
const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" };
export const log = {
info: console.log.bind(console, "[HOST]"),
warn: console.warn.bind(console, "[HOST]"),
error: console.error.bind(console, "[HOST]"),
};
export interface ServerInfo {
name: string;
client: Client;
tools: Map<string, Tool>;
appHtmlCache: Map<string, string>;
}
export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
const client = new Client(IMPLEMENTATION);
log.info("Connecting to server:", serverUrl.href);
await client.connect(new StreamableHTTPClientTransport(serverUrl));
log.info("Connection successful");
const name = client.getServerVersion()?.name ?? serverUrl.href;
const toolsList = await client.listTools();
const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool]));
log.info("Server tools:", Array.from(tools.keys()));
return { name, client, tools, appHtmlCache: new Map() };
}
interface UiResourceData {
html: string;
csp?: {
connectDomains?: string[];
resourceDomains?: string[];
};
}
export interface ToolCallInfo {
serverInfo: ServerInfo;
tool: Tool;
input: Record<string, unknown>;
resultPromise: Promise<CallToolResult>;
appResourcePromise?: Promise<UiResourceData>;
}
export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required<ToolCallInfo> {
return !!toolCallInfo.appResourcePromise;
}
export function callTool(
serverInfo: ServerInfo,
name: string,
input: Record<string, unknown>,
): ToolCallInfo {
log.info("Calling tool", name, "with input", input);
const resultPromise = serverInfo.client.callTool({ name, arguments: input }) as Promise<CallToolResult>;
const tool = serverInfo.tools.get(name);
if (!tool) {
throw new Error(`Unknown tool: ${name}`);
}
const toolCallInfo: ToolCallInfo = { serverInfo, tool, input, resultPromise };
const uiResourceUri = getToolUiResourceUri(tool);
if (uiResourceUri) {
toolCallInfo.appResourcePromise = getUiResource(serverInfo, uiResourceUri);
}
return toolCallInfo;
}
async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiResourceData> {
log.info("Reading UI resource:", uri);
const resource = await serverInfo.client.readResource({ uri });
if (!resource) {
throw new Error(`Resource not found: ${uri}`);
}
if (resource.contents.length !== 1) {
throw new Error(`Unexpected contents count: ${resource.contents.length}`);
}
const content = resource.contents[0];
// Per the MCP App specification, "text/html;profile=mcp-app" signals this
// resource is indeed for an MCP App UI.
if (content.mimeType !== RESOURCE_MIME_TYPE) {
throw new Error(`Unsupported MIME type: ${content.mimeType}`);
}
const html = "blob" in content ? atob(content.blob) : content.text;
// Extract CSP metadata from resource content._meta.ui.csp (or content.meta for Python SDK)
log.info("Resource content keys:", Object.keys(content));
log.info("Resource content._meta:", (content as any)._meta);
// Try both _meta (spec) and meta (Python SDK quirk)
const contentMeta = (content as any)._meta || (content as any).meta;
const csp = contentMeta?.ui?.csp;
return { html, csp };
}
export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
// Prevent reload
if (iframe.src) return Promise.resolve(false);
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
const readyNotification: McpUiSandboxProxyReadyNotification["method"] =
"ui/notifications/sandbox-proxy-ready";
const readyPromise = new Promise<boolean>((resolve) => {
const listener = ({ source, data }: MessageEvent) => {
if (source === iframe.contentWindow && data?.method === readyNotification) {
log.info("Sandbox proxy loaded")
window.removeEventListener("message", listener);
resolve(true);
}
};
window.addEventListener("message", listener);
});
log.info("Loading sandbox proxy...");
iframe.src = SANDBOX_PROXY_URL.href;
return readyPromise;
}
export async function initializeApp(
iframe: HTMLIFrameElement,
appBridge: AppBridge,
{ input, resultPromise, appResourcePromise }: Required<ToolCallInfo>,
): Promise<void> {
const appInitializedPromise = hookInitializedCallback(appBridge);
// Connect app bridge (triggers MCP initialization handshake)
//
// IMPORTANT: Pass `iframe.contentWindow` as BOTH target and source to ensure
// this proxy only responds to messages from its specific iframe.
await appBridge.connect(
new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
);
// Load inner iframe HTML with CSP metadata
const { html, csp } = await appResourcePromise;
log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
await appBridge.sendSandboxResourceReady({ html, csp });
// Wait for inner iframe to be ready
log.info("Waiting for MCP App to initialize...");
await appInitializedPromise;
log.info("MCP App initialized");
// Send tool call input to iframe
log.info("Sending tool call input to MCP App:", input);
appBridge.sendToolInput({ arguments: input });
// Schedule tool call result (or cancellation) to be sent to MCP App
resultPromise.then(
(result) => {
log.info("Sending tool call result to MCP App:", result);
appBridge.sendToolResult(result);
},
(error) => {
log.error("Tool call failed, sending cancellation to MCP App:", error);
appBridge.sendToolCancelled({
reason: error instanceof Error ? error.message : String(error),
});
},
);
}
/**
* Hooks into `AppBridge.oninitialized` and returns a Promise that resolves when
* the MCP App is initialized (i.e., when the inner iframe is ready).
*/
function hookInitializedCallback(appBridge: AppBridge): Promise<void> {
const oninitialized = appBridge.oninitialized;
return new Promise<void>((resolve) => {
appBridge.oninitialized = (...args) => {
resolve();
appBridge.oninitialized = oninitialized;
appBridge.oninitialized?.(...args);
};
});
}
export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): AppBridge {
const serverCapabilities = serverInfo.client.getServerCapabilities();
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
openLinks: {},
serverTools: serverCapabilities?.tools,
serverResources: serverCapabilities?.resources,
});
// Register all handlers before calling connect(). The Guest UI can start
// sending requests immediately after the initialization handshake, so any
// handlers registered after connect() might miss early requests.
appBridge.onmessage = async (params, _extra) => {
log.info("Message from MCP App:", params);
return {};
};
appBridge.onopenlink = async (params, _extra) => {
log.info("Open link request:", params);
window.open(params.url, "_blank", "noopener,noreferrer");
return {};
};
appBridge.onloggingmessage = (params) => {
log.info("Log message from MCP App:", params);
};
appBridge.onsizechange = async ({ width, height }) => {
// The MCP App has requested a `width` and `height`, but if
// `box-sizing: border-box` is applied to the outer iframe element, then we
// must add border thickness to `width` and `height` to compute the actual
// necessary width and height (in order to prevent a resize feedback loop).
const style = getComputedStyle(iframe);
const isBorderBox = style.boxSizing === "border-box";
// Animate the change for a smooth transition.
const from: Keyframe = {};
const to: Keyframe = {};
if (width !== undefined) {
if (isBorderBox) {
width += parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
}
// Use min-width instead of width to allow responsive growing.
// With auto-resize (the default), the app reports its minimum content
// width; we honor that as a floor but allow the iframe to expand when
// the host layout allows. And we use `min(..., 100%)` so that the iframe
// shrinks with its container.
from.minWidth = `${iframe.offsetWidth}px`;
iframe.style.minWidth = to.minWidth = `min(${width}px, 100%)`;
}
if (height !== undefined) {
if (isBorderBox) {
height += parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
}
from.height = `${iframe.offsetHeight}px`;
iframe.style.height = to.height = `${height}px`;
}
iframe.animate([from, to], { duration: 300, easing: "ease-out" });
};
return appBridge;
}