Skip to content

Commit 571b365

Browse files
QuantGeekDevclaude
andcommitted
feat: add MCP Apps support (interactive UI from tools)
Implement SEP-1865 MCP Apps extension, enabling tools to deliver interactive HTML UIs that render inline in Claude, ChatGPT, VS Code, and other MCP hosts. Two modes of use: - Mode A: Standalone MCPApp class (auto-discovered from dist/apps/) - Mode B: Tool-attached app via `app` property on MCPTool Key changes: - MCPApp base class with validation, resource definition, HTML serving - MCPTool extended with optional `app` property and `_meta.ui` injection - AppLoader for directory-based auto-discovery - MCPServer integration: UI resource registration, app-only tool filtering from tools/list, extension capability advertising, dev mode with live-reload vs production caching - CLI: `mcp add app <name>` scaffolding with HTML template - 46 unit tests across 3 test files No new npm dependencies. Backward compatible — tools without `app` property are completely unaffected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 91c2233 commit 571b365

12 files changed

Lines changed: 1403 additions & 2 deletions

File tree

src/apps/BaseApp.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {
2+
AppProtocol,
3+
AppToolDefinition,
4+
AppCSPConfig,
5+
AppPermissionsConfig,
6+
AppUIResourceMeta,
7+
MCP_APP_MIME_TYPE,
8+
} from './types.js';
9+
import { validateAppUri, validateAppToolVisibility, warnContentSize } from './validation.js';
10+
import { ResourceContent, ResourceDefinition } from '../resources/BaseResource.js';
11+
12+
/** UI configuration for an MCPApp. */
13+
export interface AppUIConfig {
14+
/** URI for this app's UI resource. Must start with "ui://". */
15+
resourceUri: string;
16+
/** Human-readable name for the UI resource. */
17+
resourceName: string;
18+
/** Optional description of the UI. */
19+
resourceDescription?: string;
20+
/** CSP configuration for the iframe sandbox. */
21+
csp?: AppCSPConfig;
22+
/** Browser permissions needed by the app. */
23+
permissions?: AppPermissionsConfig;
24+
/** Whether the host should render a visible border. */
25+
prefersBorder?: boolean;
26+
}
27+
28+
/**
29+
* Base class for standalone MCP Apps (Mode A).
30+
*
31+
* Bundles UI configuration, HTML content, and associated tool definitions
32+
* into a single auto-discoverable class. Place subclasses in `src/apps/`.
33+
*
34+
* @example
35+
* ```typescript
36+
* class DashboardApp extends MCPApp {
37+
* name = "dashboard";
38+
* ui = {
39+
* resourceUri: "ui://dashboard/view",
40+
* resourceName: "Dashboard",
41+
* };
42+
* getContent() { return readFileSync("./dashboard.html", "utf-8"); }
43+
* tools = [{
44+
* name: "show_dashboard",
45+
* description: "Show the dashboard",
46+
* schema: z.object({ range: z.string().describe("Time range") }),
47+
* execute: async (input) => fetchData(input.range),
48+
* }];
49+
* }
50+
* ```
51+
*/
52+
export abstract class MCPApp implements AppProtocol {
53+
/** Unique identifier for this app. */
54+
abstract name: string;
55+
/** UI configuration (resource URI, name, CSP, permissions). */
56+
abstract ui: AppUIConfig;
57+
/** Tools associated with this app. At least one is required. */
58+
abstract tools: AppToolDefinition[];
59+
/** Return the HTML content for the UI resource. */
60+
abstract getContent(): Promise<string> | string;
61+
62+
/** Validates app configuration. Called by MCPServer at startup. */
63+
validate(): void {
64+
validateAppUri(this.ui.resourceUri, `app "${this.name}"`);
65+
66+
if (!this.ui.resourceName) {
67+
throw new Error(`App "${this.name}" must have a ui.resourceName.`);
68+
}
69+
70+
if (!this.tools || this.tools.length === 0) {
71+
throw new Error(`App "${this.name}" must define at least one tool.`);
72+
}
73+
74+
const toolNames = new Set<string>();
75+
for (const tool of this.tools) {
76+
if (toolNames.has(tool.name)) {
77+
throw new Error(`App "${this.name}" has duplicate tool name: "${tool.name}".`);
78+
}
79+
toolNames.add(tool.name);
80+
81+
if (!tool.name || !tool.description || !tool.schema || !tool.execute) {
82+
throw new Error(
83+
`App "${this.name}" tool "${tool.name || '(unnamed)'}" must have name, description, schema, and execute.`,
84+
);
85+
}
86+
87+
validateAppToolVisibility(tool.visibility, tool.name);
88+
}
89+
}
90+
91+
/** Returns the MCP resource definition for this app's UI. */
92+
get resourceDefinition(): ResourceDefinition {
93+
return {
94+
uri: this.ui.resourceUri,
95+
name: this.ui.resourceName,
96+
description: this.ui.resourceDescription,
97+
mimeType: MCP_APP_MIME_TYPE,
98+
};
99+
}
100+
101+
/** Returns the _meta.ui metadata for resource content, or undefined if none. */
102+
get resourceMeta(): AppUIResourceMeta | undefined {
103+
const { csp, permissions, prefersBorder } = this.ui;
104+
if (!csp && !permissions && prefersBorder === undefined) return undefined;
105+
return {
106+
...(csp && { csp }),
107+
...(permissions && { permissions }),
108+
...(prefersBorder !== undefined && { prefersBorder }),
109+
};
110+
}
111+
112+
/** Reads HTML content and returns it as MCP ResourceContent. */
113+
async readResource(): Promise<ResourceContent[]> {
114+
const html = await this.getContent();
115+
warnContentSize(html, this.name);
116+
117+
const content: ResourceContent & { _meta?: Record<string, unknown> } = {
118+
uri: this.ui.resourceUri,
119+
mimeType: MCP_APP_MIME_TYPE,
120+
text: html,
121+
};
122+
123+
const meta = this.resourceMeta;
124+
if (meta) {
125+
content._meta = { ui: meta };
126+
}
127+
128+
return [content];
129+
}
130+
131+
/** Returns the _meta.ui object for a tool definition. */
132+
getToolMeta(toolName: string): { ui: { resourceUri: string; visibility?: Array<string> } } {
133+
const tool = this.tools.find((t) => t.name === toolName);
134+
const visibility = tool?.visibility ?? ['model', 'app'];
135+
return {
136+
ui: {
137+
resourceUri: this.ui.resourceUri,
138+
...(visibility && { visibility }),
139+
},
140+
};
141+
}
142+
}

src/apps/types.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type { z } from 'zod';
2+
import type { ResourceDefinition, ResourceContent } from '../resources/BaseResource.js';
3+
4+
// ── Constants ────────────────────────────────────────────────────────────────
5+
6+
/** MIME type for MCP App HTML resources. */
7+
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
8+
9+
/** Required URI scheme for MCP App resources. */
10+
export const MCP_APP_URI_SCHEME = 'ui://';
11+
12+
/** MCP extension identifier for UI support. */
13+
export const MCP_APP_EXTENSION_ID = 'io.modelcontextprotocol/ui';
14+
15+
/** Recommended maximum HTML content size in bytes (512KB). */
16+
export const MCP_APP_MAX_RECOMMENDED_SIZE = 512 * 1024;
17+
18+
// ── CSP & Permissions ────────────────────────────────────────────────────────
19+
20+
/** Content Security Policy configuration for an app's iframe sandbox. */
21+
export interface AppCSPConfig {
22+
/** Origins for network requests (fetch/XHR/WebSocket). Maps to CSP connect-src. */
23+
connectDomains?: string[];
24+
/** Origins for static resources (scripts, images, styles, fonts). */
25+
resourceDomains?: string[];
26+
/** Origins for nested iframes. Maps to CSP frame-src. */
27+
frameDomains?: string[];
28+
/** Allowed base URIs for the document. Maps to CSP base-uri. */
29+
baseUriDomains?: string[];
30+
}
31+
32+
/** Browser permissions the app may request from the host. */
33+
export interface AppPermissionsConfig {
34+
camera?: {};
35+
microphone?: {};
36+
geolocation?: {};
37+
clipboardWrite?: {};
38+
}
39+
40+
// ── Resource Metadata ────────────────────────────────────────────────────────
41+
42+
/** Metadata included in the resources/read response _meta.ui field. */
43+
export interface AppUIResourceMeta {
44+
csp?: AppCSPConfig;
45+
permissions?: AppPermissionsConfig;
46+
domain?: string;
47+
prefersBorder?: boolean;
48+
}
49+
50+
// ── Tool Visibility ──────────────────────────────────────────────────────────
51+
52+
/** Controls who can see/call a tool: the model (LLM), the app (iframe), or both. */
53+
export type AppToolVisibility = Array<'model' | 'app'>;
54+
55+
/** UI metadata attached to a tool definition via _meta.ui. */
56+
export interface AppToolMeta {
57+
resourceUri: string;
58+
visibility?: AppToolVisibility;
59+
}
60+
61+
// ── Mode B: Tool-Attached App Config ─────────────────────────────────────────
62+
63+
/** Configuration for attaching an MCP App to an existing MCPTool (Mode B). */
64+
export interface ToolAppConfig {
65+
/** URI for the UI resource. Must start with "ui://". */
66+
resourceUri: string;
67+
/** Human-readable name for the UI resource. */
68+
resourceName: string;
69+
/** Optional description of the UI resource. */
70+
resourceDescription?: string;
71+
/** HTML content — a string literal or a function returning one. */
72+
content: (() => Promise<string> | string) | string;
73+
/** CSP configuration for the iframe sandbox. */
74+
csp?: AppCSPConfig;
75+
/** Browser permissions needed by the app. */
76+
permissions?: AppPermissionsConfig;
77+
/** Whether the host should render a visible border. */
78+
prefersBorder?: boolean;
79+
/** Who can call this tool. Default: ["model", "app"]. */
80+
visibility?: AppToolVisibility;
81+
}
82+
83+
// ── Mode A: App Tool Definition ──────────────────────────────────────────────
84+
85+
/** A tool definition declared within an MCPApp (Mode A). */
86+
export interface AppToolDefinition {
87+
name: string;
88+
description: string;
89+
schema: z.ZodObject<any>;
90+
/** Who can call this tool. Default: ["model", "app"]. */
91+
visibility?: AppToolVisibility;
92+
/** The tool handler. */
93+
execute: (input: any) => Promise<unknown>;
94+
}
95+
96+
// ── Mode A: App Protocol ─────────────────────────────────────────────────────
97+
98+
/** Interface that all MCPApp instances must satisfy. */
99+
export interface AppProtocol {
100+
name: string;
101+
ui: {
102+
resourceUri: string;
103+
resourceName: string;
104+
resourceDescription?: string;
105+
csp?: AppCSPConfig;
106+
permissions?: AppPermissionsConfig;
107+
prefersBorder?: boolean;
108+
};
109+
tools: AppToolDefinition[];
110+
getContent(): Promise<string> | string;
111+
validate(): void;
112+
resourceDefinition: ResourceDefinition;
113+
resourceMeta: AppUIResourceMeta | undefined;
114+
readResource(): Promise<ResourceContent[]>;
115+
getToolMeta(toolName: string): { ui: { resourceUri: string; visibility?: Array<string> } };
116+
}

src/apps/validation.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { MCP_APP_URI_SCHEME, MCP_APP_MAX_RECOMMENDED_SIZE } from './types.js';
2+
import { logger } from '../core/Logger.js';
3+
4+
/**
5+
* Validates that a resource URI uses the required ui:// scheme.
6+
* @throws Error if the URI is invalid.
7+
*/
8+
export function validateAppUri(uri: string, context: string): void {
9+
if (!uri.startsWith(MCP_APP_URI_SCHEME)) {
10+
throw new Error(
11+
`Invalid app resource URI in ${context}: "${uri}". ` +
12+
`URI must start with "${MCP_APP_URI_SCHEME}".`,
13+
);
14+
}
15+
if (uri === MCP_APP_URI_SCHEME) {
16+
throw new Error(
17+
`Invalid app resource URI in ${context}: "${uri}". ` +
18+
`URI must have a path after "${MCP_APP_URI_SCHEME}".`,
19+
);
20+
}
21+
}
22+
23+
/**
24+
* Validates the visibility array for an app tool.
25+
* @throws Error if visibility contains invalid values or is empty.
26+
*/
27+
export function validateAppToolVisibility(
28+
visibility: Array<string> | undefined,
29+
toolName: string,
30+
): void {
31+
if (!visibility) return;
32+
const valid = ['model', 'app'];
33+
for (const v of visibility) {
34+
if (!valid.includes(v)) {
35+
throw new Error(
36+
`Invalid visibility "${v}" for tool "${toolName}". Must be "model" or "app".`,
37+
);
38+
}
39+
}
40+
if (visibility.length === 0) {
41+
throw new Error(
42+
`Empty visibility array for tool "${toolName}". ` +
43+
`Must contain at least one of: "model", "app".`,
44+
);
45+
}
46+
}
47+
48+
/**
49+
* Logs a warning if the HTML content exceeds the recommended size.
50+
*/
51+
export function warnContentSize(content: string, appName: string): void {
52+
const size = Buffer.byteLength(content, 'utf-8');
53+
if (size > MCP_APP_MAX_RECOMMENDED_SIZE) {
54+
const sizeKB = Math.round(size / 1024);
55+
logger.warn(
56+
`App "${appName}" HTML content is ${sizeKB}KB. ` +
57+
`Recommended maximum is ${MCP_APP_MAX_RECOMMENDED_SIZE / 1024}KB for optimal performance.`,
58+
);
59+
}
60+
}
61+
62+
/**
63+
* Returns true if the tool should be hidden from the LLM agent's tools/list.
64+
*/
65+
export function isAppOnlyTool(visibility?: Array<string>): boolean {
66+
if (!visibility) return false;
67+
return !visibility.includes('model');
68+
}

src/cli/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createProject } from './project/create.js';
55
import { addTool } from './project/add-tool.js';
66
import { addPrompt } from './project/add-prompt.js';
77
import { addResource } from './project/add-resource.js';
8+
import { addApp } from './project/add-app.js';
89
import { buildFramework } from './framework/build.js';
910
import { validateCommand } from './commands/validate.js';
1011

@@ -51,6 +52,12 @@ program
5152
.description('Add a new resource')
5253
.argument('[name]', 'resource name')
5354
.action(addResource)
55+
)
56+
.addCommand(
57+
new Command('app')
58+
.description('Add a new app with interactive UI')
59+
.argument('[name]', 'app name')
60+
.action(addApp)
5461
);
5562

5663
program.addCommand(validateCommand);

0 commit comments

Comments
 (0)